@voltro/cli 0.1.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 (626) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +20236 -0
  6. package/bin/voltro.mjs +43 -0
  7. package/dist/bin.d.ts +1 -0
  8. package/dist/bin.js +9 -0
  9. package/dist/commands-CfPH2Wf4.js +18061 -0
  10. package/dist/frameworkInspectState-CX2250XB.js +86 -0
  11. package/dist/index.d.ts +48 -0
  12. package/dist/index.js +5 -0
  13. package/dist/inspectState.d.ts +116 -0
  14. package/dist/inspectState.js +2 -0
  15. package/dist/startup.d.ts +26 -0
  16. package/dist/startup.js +2 -0
  17. package/dist/startupRunner-CRhuUl91.js +71 -0
  18. package/package.json +88 -0
  19. package/templates/AGENTS.core.md +258 -0
  20. package/templates/AGENTS.md +351 -0
  21. package/templates/agent-docs/_index.md +93 -0
  22. package/templates/agent-docs/_manifest.json +655 -0
  23. package/templates/agent-docs/ai.md +1845 -0
  24. package/templates/agent-docs/authentication.md +1788 -0
  25. package/templates/agent-docs/caching.md +624 -0
  26. package/templates/agent-docs/cli.md +1650 -0
  27. package/templates/agent-docs/configuration.md +295 -0
  28. package/templates/agent-docs/data.md +2172 -0
  29. package/templates/agent-docs/database/advancedqueries.md +1583 -0
  30. package/templates/agent-docs/database/columntypes.md +1200 -0
  31. package/templates/agent-docs/database/hosting.md +881 -0
  32. package/templates/agent-docs/database/migrations.md +2938 -0
  33. package/templates/agent-docs/database/misc.md +270 -0
  34. package/templates/agent-docs/database/overview.md +108 -0
  35. package/templates/agent-docs/database/querying.md +1622 -0
  36. package/templates/agent-docs/database/scaling.md +331 -0
  37. package/templates/agent-docs/database/schema.md +1458 -0
  38. package/templates/agent-docs/database/seedsdialects.md +1235 -0
  39. package/templates/agent-docs/database/transactions.md +285 -0
  40. package/templates/agent-docs/deployment.md +999 -0
  41. package/templates/agent-docs/internationalization.md +359 -0
  42. package/templates/agent-docs/introduction.md +438 -0
  43. package/templates/agent-docs/multi-tenancy.md +610 -0
  44. package/templates/agent-docs/observability.md +350 -0
  45. package/templates/agent-docs/plugins.md +1273 -0
  46. package/templates/agent-docs/reference.md +978 -0
  47. package/templates/agent-docs/routing.md +1553 -0
  48. package/templates/agent-docs/scheduling.md +666 -0
  49. package/templates/agent-docs/schema-driven-ui.md +607 -0
  50. package/templates/agent-docs/security.md +42 -0
  51. package/templates/agent-docs/templates/apibackends.md +3214 -0
  52. package/templates/agent-docs/templates/appshells.md +2062 -0
  53. package/templates/agent-docs/templates/custom.md +128 -0
  54. package/templates/agent-docs/templates/overview.md +122 -0
  55. package/templates/agent-docs/templates/serverless.md +315 -0
  56. package/templates/agent-docs/testing.md +376 -0
  57. package/templates/agent-docs/workflows.md +1351 -0
  58. package/templates/apps/api-ai/README.md +105 -0
  59. package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
  60. package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
  61. package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
  62. package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
  63. package/templates/apps/api-ai/app.config.ts +57 -0
  64. package/templates/apps/api-ai/database/schema.ts +67 -0
  65. package/templates/apps/api-ai/package.json +27 -0
  66. package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
  67. package/templates/apps/api-ai/template.json +6 -0
  68. package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
  69. package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
  70. package/templates/apps/api-ai/tsconfig.json +5 -0
  71. package/templates/apps/api-auth/.env +17 -0
  72. package/templates/apps/api-auth/README.md +109 -0
  73. package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
  74. package/templates/apps/api-auth/actions/me.action.ts +19 -0
  75. package/templates/apps/api-auth/app.config.ts +59 -0
  76. package/templates/apps/api-auth/database/schema.ts +26 -0
  77. package/templates/apps/api-auth/package.json +28 -0
  78. package/templates/apps/api-auth/template.json +6 -0
  79. package/templates/apps/api-auth/tests/me.test.ts +37 -0
  80. package/templates/apps/api-auth/tsconfig.json +5 -0
  81. package/templates/apps/api-backend/README.md +25 -0
  82. package/templates/apps/api-backend/app.config.ts +39 -0
  83. package/templates/apps/api-backend/database/schema.ts +57 -0
  84. package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
  85. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
  86. package/templates/apps/api-backend/package.json +28 -0
  87. package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
  88. package/templates/apps/api-backend/queries/notes.query.ts +20 -0
  89. package/templates/apps/api-backend/template.json +6 -0
  90. package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
  91. package/templates/apps/api-backend/tsconfig.json +5 -0
  92. package/templates/apps/api-backend-deactivation/README.md +49 -0
  93. package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
  94. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
  95. package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
  96. package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
  97. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
  98. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
  99. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
  100. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
  101. package/templates/apps/api-backend-deactivation/package.json +28 -0
  102. package/templates/apps/api-backend-deactivation/template.json +6 -0
  103. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
  104. package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
  105. package/templates/apps/api-backend-mail/README.md +39 -0
  106. package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
  107. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
  108. package/templates/apps/api-backend-mail/app.config.ts +34 -0
  109. package/templates/apps/api-backend-mail/database/schema.ts +57 -0
  110. package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
  111. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
  112. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
  113. package/templates/apps/api-backend-mail/package.json +30 -0
  114. package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
  115. package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
  116. package/templates/apps/api-backend-mail/template.json +6 -0
  117. package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
  118. package/templates/apps/api-backend-mail/tsconfig.json +5 -0
  119. package/templates/apps/api-backend-mariadb/.env.example +35 -0
  120. package/templates/apps/api-backend-mariadb/README.md +31 -0
  121. package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
  122. package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
  123. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
  124. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
  125. package/templates/apps/api-backend-mariadb/package.json +30 -0
  126. package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
  127. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
  128. package/templates/apps/api-backend-mariadb/template.json +6 -0
  129. package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
  130. package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
  131. package/templates/apps/api-backend-storage/README.md +86 -0
  132. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
  133. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
  134. package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
  135. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
  136. package/templates/apps/api-backend-storage/app.config.ts +38 -0
  137. package/templates/apps/api-backend-storage/database/schema.ts +57 -0
  138. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
  139. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
  140. package/templates/apps/api-backend-storage/package.json +27 -0
  141. package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
  142. package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
  143. package/templates/apps/api-backend-storage/template.json +6 -0
  144. package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
  145. package/templates/apps/api-backend-storage/tsconfig.json +5 -0
  146. package/templates/apps/api-data-advanced/.env +17 -0
  147. package/templates/apps/api-data-advanced/README.md +88 -0
  148. package/templates/apps/api-data-advanced/app.config.ts +56 -0
  149. package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
  150. package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
  151. package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
  152. package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
  153. package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
  154. package/templates/apps/api-data-advanced/database/index.ts +23 -0
  155. package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
  156. package/templates/apps/api-data-advanced/package.json +28 -0
  157. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
  158. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
  159. package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
  160. package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
  161. package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
  162. package/templates/apps/api-data-advanced/template.json +6 -0
  163. package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
  164. package/templates/apps/api-data-advanced/tsconfig.json +5 -0
  165. package/templates/apps/api-durable/README.md +81 -0
  166. package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
  167. package/templates/apps/api-durable/app.config.ts +41 -0
  168. package/templates/apps/api-durable/database/schema.ts +65 -0
  169. package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
  170. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
  171. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
  172. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
  173. package/templates/apps/api-durable/package.json +28 -0
  174. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
  175. package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
  176. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
  177. package/templates/apps/api-durable/template.json +6 -0
  178. package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
  179. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
  180. package/templates/apps/api-durable/tsconfig.json +5 -0
  181. package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
  182. package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
  183. package/templates/apps/api-feature-flags/README.md +63 -0
  184. package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
  185. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
  186. package/templates/apps/api-feature-flags/app.config.ts +41 -0
  187. package/templates/apps/api-feature-flags/database/schema.ts +37 -0
  188. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
  189. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
  190. package/templates/apps/api-feature-flags/package.json +29 -0
  191. package/templates/apps/api-feature-flags/template.json +6 -0
  192. package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
  193. package/templates/apps/api-feature-flags/tsconfig.json +5 -0
  194. package/templates/apps/api-governance/.env +4 -0
  195. package/templates/apps/api-governance/README.md +61 -0
  196. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
  197. package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
  198. package/templates/apps/api-governance/app.config.ts +35 -0
  199. package/templates/apps/api-governance/database/schema.ts +41 -0
  200. package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
  201. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
  202. package/templates/apps/api-governance/package.json +29 -0
  203. package/templates/apps/api-governance/template.json +6 -0
  204. package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
  205. package/templates/apps/api-governance/tsconfig.json +5 -0
  206. package/templates/apps/api-kv/README.md +100 -0
  207. package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
  208. package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
  209. package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
  210. package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
  211. package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
  212. package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
  213. package/templates/apps/api-kv/app.config.ts +48 -0
  214. package/templates/apps/api-kv/database/schema.ts +62 -0
  215. package/templates/apps/api-kv/package.json +28 -0
  216. package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
  217. package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
  218. package/templates/apps/api-kv/template.json +6 -0
  219. package/templates/apps/api-kv/tests/sync.test.ts +103 -0
  220. package/templates/apps/api-kv/tsconfig.json +5 -0
  221. package/templates/apps/api-moderation/README.md +46 -0
  222. package/templates/apps/api-moderation/app.config.ts +33 -0
  223. package/templates/apps/api-moderation/database/schema.ts +45 -0
  224. package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
  225. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
  226. package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
  227. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
  228. package/templates/apps/api-moderation/package.json +29 -0
  229. package/templates/apps/api-moderation/template.json +6 -0
  230. package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
  231. package/templates/apps/api-moderation/tsconfig.json +5 -0
  232. package/templates/apps/api-observability/README.md +106 -0
  233. package/templates/apps/api-observability/app.config.ts +32 -0
  234. package/templates/apps/api-observability/database/schema.ts +38 -0
  235. package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
  237. package/templates/apps/api-observability/package.json +29 -0
  238. package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
  239. package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
  240. package/templates/apps/api-observability/template.json +6 -0
  241. package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
  242. package/templates/apps/api-observability/tsconfig.json +5 -0
  243. package/templates/apps/api-ratelimit/README.md +49 -0
  244. package/templates/apps/api-ratelimit/app.config.ts +42 -0
  245. package/templates/apps/api-ratelimit/database/schema.ts +37 -0
  246. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
  247. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
  248. package/templates/apps/api-ratelimit/package.json +29 -0
  249. package/templates/apps/api-ratelimit/template.json +6 -0
  250. package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
  251. package/templates/apps/api-ratelimit/tsconfig.json +5 -0
  252. package/templates/apps/api-rbac/README.md +59 -0
  253. package/templates/apps/api-rbac/app.config.ts +48 -0
  254. package/templates/apps/api-rbac/database/schema.ts +37 -0
  255. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
  256. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
  257. package/templates/apps/api-rbac/package.json +29 -0
  258. package/templates/apps/api-rbac/template.json +6 -0
  259. package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
  260. package/templates/apps/api-rbac/tsconfig.json +5 -0
  261. package/templates/apps/api-rest/README.md +85 -0
  262. package/templates/apps/api-rest/app.config.ts +70 -0
  263. package/templates/apps/api-rest/database/schema.ts +52 -0
  264. package/templates/apps/api-rest/lib/product.ts +30 -0
  265. package/templates/apps/api-rest/package.json +27 -0
  266. package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
  267. package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
  268. package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
  269. package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
  270. package/templates/apps/api-rest/template.json +6 -0
  271. package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
  272. package/templates/apps/api-rest/tsconfig.json +5 -0
  273. package/templates/apps/api-saas/README.md +106 -0
  274. package/templates/apps/api-saas/app.config.ts +51 -0
  275. package/templates/apps/api-saas/database/schema.ts +50 -0
  276. package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
  277. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
  278. package/templates/apps/api-saas/package.json +31 -0
  279. package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
  280. package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
  281. package/templates/apps/api-saas/template.json +6 -0
  282. package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
  283. package/templates/apps/api-saas/tsconfig.json +5 -0
  284. package/templates/apps/api-search/README.md +68 -0
  285. package/templates/apps/api-search/app.config.ts +31 -0
  286. package/templates/apps/api-search/database/schema.ts +47 -0
  287. package/templates/apps/api-search/lib/search.ts +27 -0
  288. package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
  289. package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
  290. package/templates/apps/api-search/package.json +29 -0
  291. package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
  292. package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
  293. package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
  294. package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
  295. package/templates/apps/api-search/template.json +6 -0
  296. package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
  297. package/templates/apps/api-search/tsconfig.json +5 -0
  298. package/templates/apps/api-versioning/README.md +51 -0
  299. package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
  300. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
  301. package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
  302. package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
  303. package/templates/apps/api-versioning/app.config.ts +24 -0
  304. package/templates/apps/api-versioning/database/schema.ts +38 -0
  305. package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
  306. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
  307. package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
  308. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
  309. package/templates/apps/api-versioning/package.json +29 -0
  310. package/templates/apps/api-versioning/template.json +6 -0
  311. package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
  312. package/templates/apps/api-versioning/tsconfig.json +5 -0
  313. package/templates/apps/api-webhooks/.env +6 -0
  314. package/templates/apps/api-webhooks/README.md +106 -0
  315. package/templates/apps/api-webhooks/app.config.ts +16 -0
  316. package/templates/apps/api-webhooks/database/schema.ts +49 -0
  317. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
  318. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
  319. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
  320. package/templates/apps/api-webhooks/package.json +28 -0
  321. package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
  322. package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
  323. package/templates/apps/api-webhooks/template.json +6 -0
  324. package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
  325. package/templates/apps/api-webhooks/tsconfig.json +5 -0
  326. package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
  327. package/templates/apps/changelog/README.md +77 -0
  328. package/templates/apps/changelog/app.config.ts +27 -0
  329. package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
  330. package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
  331. package/templates/apps/changelog/package.json +30 -0
  332. package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
  333. package/templates/apps/changelog/src/globals.css +66 -0
  334. package/templates/apps/changelog/src/globals.d.ts +16 -0
  335. package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
  336. package/templates/apps/changelog/src/lib/locale.ts +55 -0
  337. package/templates/apps/changelog/src/lib/releases.ts +21 -0
  338. package/templates/apps/changelog/src/locales/de.ts +22 -0
  339. package/templates/apps/changelog/src/locales/en.ts +30 -0
  340. package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
  341. package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
  342. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
  343. package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
  344. package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
  345. package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
  346. package/templates/apps/changelog/src/pages/index.tsx +55 -0
  347. package/templates/apps/changelog/src/pages/layout.tsx +84 -0
  348. package/templates/apps/changelog/template.json +6 -0
  349. package/templates/apps/changelog/tsconfig.json +11 -0
  350. package/templates/apps/edge-functions/README.md +55 -0
  351. package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
  352. package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
  353. package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
  354. package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
  355. package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
  356. package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
  357. package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
  358. package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
  359. package/templates/apps/edge-functions/package.json +21 -0
  360. package/templates/apps/edge-functions/template.json +6 -0
  361. package/templates/apps/edge-functions/tsconfig.json +10 -0
  362. package/templates/apps/frontend-admin/README.md +40 -0
  363. package/templates/apps/frontend-admin/app.config.ts +37 -0
  364. package/templates/apps/frontend-admin/package.json +30 -0
  365. package/templates/apps/frontend-admin/src/config.ts +8 -0
  366. package/templates/apps/frontend-admin/src/globals.css +76 -0
  367. package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
  368. package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
  369. package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
  370. package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
  371. package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
  372. package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
  373. package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
  374. package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
  375. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
  376. package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
  377. package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
  378. package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
  379. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
  380. package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
  381. package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
  382. package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
  383. package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
  384. package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
  385. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
  386. package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
  387. package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
  388. package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
  389. package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
  390. package/templates/apps/frontend-admin/template.json +6 -0
  391. package/templates/apps/frontend-admin/tsconfig.json +11 -0
  392. package/templates/apps/frontend-app/README.md +98 -0
  393. package/templates/apps/frontend-app/app.config.ts +43 -0
  394. package/templates/apps/frontend-app/package.json +30 -0
  395. package/templates/apps/frontend-app/src/locales/de.ts +36 -0
  396. package/templates/apps/frontend-app/src/locales/en.ts +43 -0
  397. package/templates/apps/frontend-app/src/locales/index.ts +15 -0
  398. package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
  399. package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
  400. package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
  401. package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
  402. package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
  403. package/templates/apps/frontend-app/template.json +6 -0
  404. package/templates/apps/frontend-app/tsconfig.json +11 -0
  405. package/templates/apps/frontend-blank/README.md +18 -0
  406. package/templates/apps/frontend-blank/app.config.ts +29 -0
  407. package/templates/apps/frontend-blank/package.json +29 -0
  408. package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
  409. package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
  410. package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
  411. package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
  412. package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
  413. package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
  414. package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
  415. package/templates/apps/frontend-blank/template.json +6 -0
  416. package/templates/apps/frontend-blank/tsconfig.json +11 -0
  417. package/templates/apps/frontend-contact/README.md +65 -0
  418. package/templates/apps/frontend-contact/app.config.ts +25 -0
  419. package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
  420. package/templates/apps/frontend-contact/package.json +33 -0
  421. package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
  422. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
  423. package/templates/apps/frontend-contact/src/config.ts +12 -0
  424. package/templates/apps/frontend-contact/src/globals.css +84 -0
  425. package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
  426. package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
  427. package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
  428. package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
  429. package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
  430. package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
  431. package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
  432. package/templates/apps/frontend-contact/template.json +6 -0
  433. package/templates/apps/frontend-contact/tsconfig.json +11 -0
  434. package/templates/apps/frontend-dashboard/README.md +54 -0
  435. package/templates/apps/frontend-dashboard/app.config.ts +39 -0
  436. package/templates/apps/frontend-dashboard/package.json +29 -0
  437. package/templates/apps/frontend-dashboard/src/config.ts +8 -0
  438. package/templates/apps/frontend-dashboard/src/globals.css +72 -0
  439. package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
  440. package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
  441. package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
  442. package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
  443. package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
  444. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
  445. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
  446. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
  447. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
  448. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
  449. package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
  450. package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
  451. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
  452. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
  453. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
  454. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
  455. package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
  456. package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
  457. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
  458. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
  459. package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
  460. package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
  461. package/templates/apps/frontend-dashboard/template.json +6 -0
  462. package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
  463. package/templates/apps/frontend-docs/README.md +19 -0
  464. package/templates/apps/frontend-docs/app.config.ts +25 -0
  465. package/templates/apps/frontend-docs/package.json +29 -0
  466. package/templates/apps/frontend-docs/src/globals.css +32 -0
  467. package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
  468. package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
  469. package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
  470. package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
  471. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
  472. package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
  473. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
  474. package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
  475. package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
  476. package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
  477. package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
  478. package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
  479. package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
  480. package/templates/apps/frontend-docs/template.json +6 -0
  481. package/templates/apps/frontend-docs/tsconfig.json +11 -0
  482. package/templates/apps/frontend-i18n/README.md +61 -0
  483. package/templates/apps/frontend-i18n/app.config.ts +33 -0
  484. package/templates/apps/frontend-i18n/package.json +28 -0
  485. package/templates/apps/frontend-i18n/src/globals.css +46 -0
  486. package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
  487. package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
  488. package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
  489. package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
  490. package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
  491. package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
  492. package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
  493. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
  494. package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
  495. package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
  496. package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
  497. package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
  498. package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
  499. package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
  500. package/templates/apps/frontend-i18n/template.json +6 -0
  501. package/templates/apps/frontend-i18n/tsconfig.json +11 -0
  502. package/templates/apps/frontend-landing/README.md +17 -0
  503. package/templates/apps/frontend-landing/app.config.ts +25 -0
  504. package/templates/apps/frontend-landing/package.json +29 -0
  505. package/templates/apps/frontend-landing/src/globals.css +23 -0
  506. package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
  507. package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
  508. package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
  509. package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
  510. package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
  511. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
  512. package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
  513. package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
  514. package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
  515. package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
  516. package/templates/apps/frontend-landing/template.json +6 -0
  517. package/templates/apps/frontend-landing/tsconfig.json +11 -0
  518. package/templates/apps/frontend-spa/README.md +45 -0
  519. package/templates/apps/frontend-spa/app.config.ts +27 -0
  520. package/templates/apps/frontend-spa/package.json +29 -0
  521. package/templates/apps/frontend-spa/src/globals.css +84 -0
  522. package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
  523. package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
  524. package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
  525. package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
  526. package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
  527. package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
  528. package/templates/apps/frontend-spa/template.json +6 -0
  529. package/templates/apps/frontend-spa/tsconfig.json +11 -0
  530. package/templates/apps/frontend-ssr/README.md +68 -0
  531. package/templates/apps/frontend-ssr/app.config.ts +32 -0
  532. package/templates/apps/frontend-ssr/package.json +29 -0
  533. package/templates/apps/frontend-ssr/src/globals.css +67 -0
  534. package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
  535. package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
  536. package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
  537. package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
  538. package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
  539. package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
  540. package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
  541. package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
  542. package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
  543. package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
  544. package/templates/apps/frontend-ssr/template.json +6 -0
  545. package/templates/apps/frontend-ssr/tsconfig.json +11 -0
  546. package/templates/apps/frontend-ssr-api/README.md +50 -0
  547. package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
  548. package/templates/apps/frontend-ssr-api/package.json +30 -0
  549. package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
  550. package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
  551. package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
  552. package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
  553. package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
  554. package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
  555. package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
  556. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
  557. package/templates/apps/frontend-ssr-api/template.json +6 -0
  558. package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
  559. package/templates/apps/frontend-static-blog/README.md +49 -0
  560. package/templates/apps/frontend-static-blog/app.config.ts +34 -0
  561. package/templates/apps/frontend-static-blog/package.json +28 -0
  562. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
  563. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
  564. package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
  565. package/templates/apps/frontend-static-blog/src/globals.css +75 -0
  566. package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
  567. package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
  568. package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
  569. package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
  570. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
  571. package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
  572. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
  573. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
  574. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
  575. package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
  576. package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
  577. package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
  578. package/templates/apps/frontend-static-blog/template.json +6 -0
  579. package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
  580. package/templates/baselines/bare/.env.example +37 -0
  581. package/templates/baselines/bare/README.md +47 -0
  582. package/templates/baselines/bare/baseline.json +31 -0
  583. package/templates/baselines/bare/deploy/README.md +43 -0
  584. package/templates/baselines/bare/deploy/voltro.service.example +36 -0
  585. package/templates/baselines/compose/.env.example +46 -0
  586. package/templates/baselines/compose/README.md +69 -0
  587. package/templates/baselines/compose/baseline.json +51 -0
  588. package/templates/baselines/compose/docker/.dockerignore +38 -0
  589. package/templates/baselines/compose/docker/api.Dockerfile +57 -0
  590. package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
  591. package/templates/baselines/compose/docker/web.Dockerfile +59 -0
  592. package/templates/baselines/compose/docker-compose.dev.yml +89 -0
  593. package/templates/baselines/compose/docker-compose.prod.yml +87 -0
  594. package/templates/baselines/compose/docker-compose.yml +41 -0
  595. package/templates/baselines/compose-mariadb/.env.example +57 -0
  596. package/templates/baselines/compose-mariadb/README.md +78 -0
  597. package/templates/baselines/compose-mariadb/baseline.json +51 -0
  598. package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
  599. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
  600. package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
  601. package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
  602. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
  603. package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
  604. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
  605. package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
  606. package/templates/baselines/helm/.env.example +39 -0
  607. package/templates/baselines/helm/README.md +98 -0
  608. package/templates/baselines/helm/baseline.json +53 -0
  609. package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
  610. package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
  611. package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
  612. package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
  613. package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
  614. package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
  615. package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
  616. package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
  617. package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
  618. package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
  619. package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
  620. package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
  621. package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
  622. package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
  623. package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
  624. package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
  625. package/templates/baselines/helm/deploy/README.md +94 -0
  626. package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
@@ -0,0 +1,1845 @@
1
+ # AI
2
+
3
+ > How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/ai/overview.md -->
10
+ ## Overview
11
+
12
+ _How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework._
13
+
14
+ AI in Voltro isn't a library you bolt on. It's a primitive: agents are files, tools are files, embeddings are columns, streaming is the same WebSocket that carries queries + mutations.
15
+
16
+ The implementation is the [Vercel AI SDK](https://sdk.vercel.ai) wrapped behind the `@voltro/ai` surface so the provider choice (Anthropic, OpenAI, the Vercel AI Gateway, mock-for-tests) is one env var — or a per-agent / per-call override carrying its own key.
17
+
18
+ ## The model
19
+
20
+ ```text
21
+ ┌─────────────────────────────────────────┐
22
+ │ Vercel AI SDK │
23
+ │ anthropic / openai / gateway / mock │
24
+ └────────────────┬────────────────────────┘
25
+
26
+
27
+ ┌─────────────────────────────────────────────────────────────────┐
28
+ │ @voltro/ai │
29
+ │ generateText(...) / generateObject(...) │
30
+ │ streamText(...) │
31
+ │ embed(...) / embedMany(...) │
32
+ └────┬─────────────────────────┬──────────────────────────────────┘
33
+ │ │
34
+ ▼ ▼
35
+ ┌──────────────────────────────┐ ┌──────────────────────────────────┐
36
+ │ *.agent.tsx (descriptor) │ │ *.tool.tsx │
37
+ │ defineAgent({ name, input })│ │ defineTool({ name, input, │
38
+ │ *.agent.server.tsx (executor)│ │ output }) │
39
+ │ defineAgentExecutor(desc, { │ │ typed + wired on the executor │
40
+ │ system, tools, model }) │ │ │
41
+ └──────────────────────────────┘ └──────────────────────────────────┘
42
+ ```
43
+
44
+ ## What's in this section
45
+
46
+ - [Providers](/docs/ai/providers) — Anthropic, OpenAI, the Vercel AI Gateway, mock; per-config keys (BYOK), switching at runtime, model-wrapping middleware
47
+ - [Agents](/docs/ai/agents) — `*.agent.tsx` shape, system prompts, tool wiring
48
+ - [Tools](/docs/ai/tools) — `*.tool.tsx` shape, validation, side effects
49
+ - [Streaming](/docs/ai/streaming) — tokens-over-WebSocket, client hooks, backpressure
50
+ - [RAG](/docs/ai/rag) — pgvector, embedding mixin, hybrid search
51
+ - [Cost tracking](/docs/ai/cost-tracking) — the token `usage` returned on every call
52
+
53
+ ## Why one surface
54
+
55
+ The Vercel AI SDK is excellent. It also rev'd its public API three times in 2024. Wrapping it gives us:
56
+
57
+ 1. **One break** when the SDK changes shape — we update `@voltro/ai`, your code keeps working.
58
+ 2. **One mock implementation** for tests — `AI_PROVIDER=mock` makes every call deterministic.
59
+ 3. **Provider portability** — switch the provider with one env var, no call-site changes.
60
+
61
+ ## Conceptual differences vs. raw SDK
62
+
63
+ | Vercel AI SDK | `@voltro/ai` |
64
+ |---|---|
65
+ | `generateText(...)` | `generateText({ prompt, system })` returns an Effect |
66
+ | `streamText(...)` | `streamText({ prompt, system })` returns an Effect Stream |
67
+ | Embeddings via `embedMany([texts])` | `embed(text)` / `embedMany(texts)` |
68
+ | Tool definitions are loose objects | `defineTool({ name, input, output, … })` with Schema |
69
+ | Provider is hardcoded in code | `AI_PROVIDER` env var |
70
+
71
+ ## When NOT to use Voltro's AI surface
72
+
73
+ - **You need bleeding-edge SDK features** before they land in `@voltro/ai`. Drop down to the raw SDK via `import { anthropic } from '@ai-sdk/anthropic'`.
74
+ - **You're calling AI from outside an executor.** Workers, CLI scripts, etc. can still use `@voltro/ai` directly when they provide the right env/config.
75
+
76
+ For 95% of app code inside actions, streams, workflows, and agent helpers, use `@voltro/ai` instead of importing provider SDKs directly. The portability and test mock surface stay in one place.
77
+
78
+
79
+
80
+ ---
81
+
82
+ <!-- source: en/ai/providers.md -->
83
+ ## Providers
84
+
85
+ _Anthropic, OpenAI, the Vercel AI Gateway, mock-for-tests, per-config keys (BYOK), and switching providers via env vars without touching code._
86
+
87
+ `@voltro/ai` exposes one surface behind a provider abstraction. Pick yours via `AI_PROVIDER`. The same free functions — `generateText({ prompt })`, `generateObject({ prompt, schema })`, `streamText({ prompt })` — work against any provider.
88
+
89
+ There is no `ctx.ai`. AI lives in the free functions you import from `@voltro/ai`, not on the request context.
90
+
91
+ ## Supported providers
92
+
93
+ | Provider | `AI_PROVIDER` value | Default model | Key env var | Notes |
94
+ |---|---|---|---|---|
95
+ | Anthropic | `anthropic` | `claude-opus-4-8` | `ANTHROPIC_API_KEY` | Claude. Best for agentic tool use + long context. |
96
+ | OpenAI | `openai` | `gpt-5.5` | `OPENAI_API_KEY` | GPT models via `@ai-sdk/openai`. |
97
+ | Gateway | `gateway` | none (required) | `AI_GATEWAY_API_KEY` | The Vercel AI Gateway: ANY model the AI SDK can reach through ONE key, addressed by a `creator/model` id (`openai/gpt-5.5`, `anthropic/claude-opus-4-8`, `google/gemini-2.5-pro`). No per-vendor `@ai-sdk/*` package needed. |
98
+ | Mock | `mock` | `mock` | none | Deterministic responses for tests + CI. Echoes the prompt (or scripted output). |
99
+
100
+ `mock` is the **default** — `voltro dev` runs key-free out of the box, and every smoke test stays deterministic without a network call. Set `AI_PROVIDER=anthropic` / `openai` / `gateway` to use a real model.
101
+
102
+ **Direct provider vs gateway:** reach for a direct provider (`anthropic` / `openai`) when you want that vendor's own key + native behaviour. Reach for `gateway` when you want "any model, one key" without adding a new `@ai-sdk/*` package — the model id carries the vendor (`openai/gpt-5.5`). All four packages stay pinned to the same `@ai-sdk/provider` major; mixing a newer-major provider package in would break the shared model type.
103
+
104
+ ## Setting the provider
105
+
106
+ ```bash
107
+ AI_PROVIDER=anthropic AI_MODEL=claude-opus-4-8 ANTHROPIC_API_KEY=sk-ant-… voltro dev
108
+ ```
109
+
110
+ Env vars `providerFromEnv()` reads:
111
+
112
+ | Var | Default | Notes |
113
+ |---|---|---|
114
+ | `AI_PROVIDER` | `mock` | `mock` \| `anthropic` \| `openai` \| `gateway`. |
115
+ | `AI_MODEL` | per provider (see below) | Override the model. Defaults: `claude-opus-4-8` (anthropic), `gpt-5.5` (openai), `mock` (mock). **REQUIRED for `gateway`** — a `creator/model` id; boot throws if unset. |
116
+
117
+ Provider API keys are read by the underlying `@ai-sdk/*` packages from their standard env vars — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `AI_GATEWAY_API_KEY`. The framework doesn't read a separate `AI_API_KEY`. (To give a SINGLE agent its own key from code instead of env, see [Per-config key + base URL](#per-config-key--base-url) below.)
118
+
119
+ ## Anthropic
120
+
121
+ ```bash
122
+ AI_PROVIDER=anthropic
123
+ AI_MODEL=claude-opus-4-8
124
+ ANTHROPIC_API_KEY=sk-ant-…
125
+ ```
126
+
127
+ Anthropic-specific:
128
+
129
+ - **Prompt caching** — automatic for long, repeated prefixes via the AI SDK.
130
+ - **Tool calling** — Claude's native tool format; the framework's `defineTool(...)` adapts to it.
131
+
132
+ For long contexts Claude is the practical pick — fewer "context overflow" surprises.
133
+
134
+ ## OpenAI
135
+
136
+ ```bash
137
+ AI_PROVIDER=openai
138
+ AI_MODEL=gpt-5.5
139
+ OPENAI_API_KEY=sk-…
140
+ ```
141
+
142
+ Backed by `@ai-sdk/openai`. `AI_MODEL` is a plain OpenAI model id (`gpt-5.5`, `gpt-4o-mini`, …). For a self-hosted / Azure-style / proxy endpoint, set `baseURL` on a `ProviderConfig` (see [Per-config key + base URL](#per-config-key--base-url)) rather than an env var.
143
+
144
+ ## Vercel AI Gateway
145
+
146
+ ```bash
147
+ AI_PROVIDER=gateway
148
+ AI_MODEL=openai/gpt-5.5 # creator/model id — REQUIRED, no default
149
+ AI_GATEWAY_API_KEY=…
150
+ ```
151
+
152
+ The gateway reaches EVERY model the AI SDK can address through ONE key — you never add a per-vendor `@ai-sdk/*` package. The model id encodes the vendor: `openai/gpt-5.5`, `anthropic/claude-opus-4-8`, `google/gemini-2.5-pro`. There's no sensible default model (the id IS the choice), so `AI_PROVIDER=gateway` with no `AI_MODEL` throws at boot with a pointer to fix it.
153
+
154
+ Use the gateway when you want to switch models across vendors freely from config; use a direct provider when you want that vendor's own key + native quirks (Anthropic prompt-caching, etc.).
155
+
156
+ ### Live model catalog — `getAvailableModels`
157
+
158
+ A model-picker UI shouldn't hard-code a model list that goes stale. `getAvailableModels()` lists the gateway's models live — each with its modality + pricing — so the picker is always current:
159
+
160
+ ```ts
161
+ import { getAvailableModels } from '@voltro/ai'
162
+
163
+ // Reads AI_GATEWAY_API_KEY; pass { apiKey, baseURL } to target a specific gateway.
164
+ const models = await getAvailableModels({ modality: 'language' }) // filter optional
165
+ // → GatewayModelInfo[]: { id, name, description?, modality, pricing? }
166
+ // id — the creator/model id you pass as the model ('openai/gpt-5.5')
167
+ // modality — 'language' | 'embedding' | 'image' | 'unknown'
168
+ // pricing — { inputPer1M, outputPer1M, cachedInputPer1M? } (USD per 1M tokens)
169
+ ```
170
+
171
+ Pricing is normalised to **USD per 1,000,000 tokens**, the same shape as the cost toolkit's `ModelPrice` — so a catalog entry can feed the [cost ledger](/docs/ai/cost-tracking) directly (`estimateCostUsd(usage, { model, price })`). `getAvailableModels` is `async` (a plain Promise, not an Effect) and `gatewayProvider` is injectable for tests, so a picker query can call it without a live gateway in CI.
172
+
173
+ ## Per-config key + base URL
174
+
175
+ Every `ProviderConfig` accepts an optional `apiKey` and `baseURL`. When EITHER is set, the framework builds a **dedicated provider instance** from it (`createOpenAI({ apiKey })` / `createAnthropic(...)` / `createGateway(...)`) instead of the env-default singleton. Omit both → it falls back to the standard env var. The mock provider ignores both.
176
+
177
+ ```ts
178
+ // A one-off call against a specific key + endpoint:
179
+ const r = yield* generateText({
180
+ prompt,
181
+ provider: { name: 'openai', model: 'gpt-5.5', apiKey: process.env.TEAM_OPENAI_KEY, baseURL: 'https://my-proxy/v1' },
182
+ })
183
+ ```
184
+
185
+ This is the mechanism behind **per-agent keys** and **BYOK** (bring-your-own-key): a `defineAgentExecutor` can carry a static `model: { name, model, apiKey }`, OR a `model: (input) => ({ …, apiKey: input.apiKey })` function that derives the key from the request. The key stays server-side (the executor file never reaches the browser) and is NOT persisted — only the prompt is stored in `agent_messages`. The full pattern (dynamic model picker, cost/tier routing, BYOK, and the security footguns) lives in [Agents → Per-agent model + key](/docs/ai/agents#per-agent-model--key).
186
+
187
+ ## Mock (for tests)
188
+
189
+ ```bash
190
+ AI_PROVIDER=mock
191
+ ```
192
+
193
+ Every call returns deterministic output (it echoes the prompt, or a scripted turn sequence). Useful for:
194
+
195
+ - CI runs where you don't want real API calls
196
+ - Unit tests of agents — assert on the call shape, not the output
197
+ - Local dev when you're offline
198
+
199
+ Configure the mock per-test. `useMockAi` installs a process-global mock provider that every `generateText` / `generateObject` / `streamText` / `runAssistant` call resolves to (overriding `AI_PROVIDER`); call `reset()` to restore:
200
+
201
+ ```ts
202
+ import { useMockAi } from '@voltro/ai/test'
203
+
204
+ let mock: ReturnType<typeof useMockAi>
205
+ beforeEach(() => {
206
+ mock = useMockAi({
207
+ // canned generateText / generateObject text
208
+ generate: { text: 'Mocked summary text.' },
209
+ // streamText token sequence (drives runAssistant deltas)
210
+ stream: ['Hello, ', 'world.'],
211
+ // scripted tool-call → text turns for the tool loop (one per step)
212
+ turns: [
213
+ { toolCalls: [{ name: 'searchDocs', input: { query: 'x' } }] },
214
+ { text: 'Based on the docs.' },
215
+ ],
216
+ })
217
+ })
218
+ afterEach(() => { mock.reset() })
219
+ ```
220
+
221
+ `mockAi({...})` returns a `MockAi` value with the same fixture shape — handy as a test helper for code that wants a mock to assert against.
222
+
223
+ `throwNTimes(n, value)` is a helper for retry tests — a function that throws the first `n` calls, then returns `value`.
224
+
225
+ ## The call surface
226
+
227
+ One-shot text:
228
+
229
+ ```ts
230
+ import { generateText } from '@voltro/ai'
231
+ import { Effect } from 'effect'
232
+
233
+ export default (input: { prompt: string }) =>
234
+ Effect.gen(function* () {
235
+ const { text, usage } = yield* generateText({ prompt: input.prompt })
236
+ return { text }
237
+ })
238
+ ```
239
+
240
+ `GenerateTextOptions` is `{ prompt, system?, provider?, fallbacks?, maxTokens? }`. There is no `messages`/`effort` shape — the prompt is a single string the SDK wraps as the user turn; `system` steers it. `fallbacks` is a [provider fallback chain](#fallback-chain--survive-a-provider-outage).
241
+
242
+ Structured output:
243
+
244
+ ```ts
245
+ import { generateObject } from '@voltro/ai'
246
+ import { Schema } from 'effect'
247
+
248
+ const Summary = Schema.Struct({ title: Schema.String, bullets: Schema.Array(Schema.String) })
249
+
250
+ const { object } = yield* generateObject({ prompt: input.text, schema: Summary })
251
+ ```
252
+
253
+ ## Switching providers per call
254
+
255
+ Pass `provider` to override the env default for one call:
256
+
257
+ ```ts
258
+ import { generateText } from '@voltro/ai'
259
+
260
+ const r = yield* generateText({
261
+ prompt,
262
+ system,
263
+ provider: { name: 'anthropic', model: 'claude-opus-4-8' },
264
+ })
265
+ ```
266
+
267
+ `provider` is a `ProviderConfig` — a **discriminated union on `name`**, so each provider only accepts the fields that apply to it:
268
+
269
+ ```ts
270
+ type ProviderConfig =
271
+ | { name: 'mock'; model?: string; mockText?: string; script?: MockScript } // mock-only fields
272
+ | { name: 'anthropic'; model: AnthropicModel; apiKey?: string; baseURL?: string }
273
+ | { name: 'openai'; model: OpenAIModel; apiKey?: string; baseURL?: string }
274
+ | { name: 'gateway'; model: `${string}/${string}`; apiKey?: string; baseURL?: string } // creator/model
275
+ ```
276
+
277
+ The mock-only `mockText` / `script` can't appear on a real provider (the type rejects it), the gateway's `model` is a `creator/model`-typed string (a bare `'gpt-5.5'` is a compile error, not a boot crash), and the per-provider model-id types (`AnthropicModel` / `OpenAIModel`) are **open unions** — known ids autocomplete, but any string the provider ships tomorrow still type-checks. Use `provider` for per-request model selection (e.g. a cheaper model on a fallback path), or to pin a specific key/endpoint (see [Per-config key + base URL](#per-config-key--base-url)).
278
+
279
+ For runtime provider switching across a whole layer, bind an `AiServiceImpl` to the `AiService` Context tag at boot and read it with `yield* AiService` — `defaultAiService` (backed by `providerFromEnv`) is the default.
280
+
281
+ ## Fallback chain — survive a provider outage
282
+
283
+ Pass an ordered `fallbacks` list to fall through to another provider/model when the primary FAILS. When the primary call fails — after its own retries — the call re-runs against `fallbacks[0]`, then `fallbacks[1]`, … until one succeeds; exhausting every option surfaces the LAST provider's typed error. So an Anthropic outage transparently drains to OpenAI (or the gateway) when a key is configured:
284
+
285
+ ```ts
286
+ import { generateText } from '@voltro/ai'
287
+
288
+ const r = yield* generateText({
289
+ prompt,
290
+ provider: { name: 'anthropic', model: 'claude-opus-4-8' },
291
+ fallbacks: [
292
+ { name: 'openai', model: 'gpt-5.5' },
293
+ { name: 'gateway', model: 'google/gemini-2.5-pro' },
294
+ ],
295
+ })
296
+ ```
297
+
298
+ - **Applies to `generateText`, `generateObject`, `generateObjectWithTools`, and the stream surface.** For streaming, use `streamTextWithFallback(options)` or `streamTextWithRetry(options, retry)` — the latter retries each provider `maxAttempts` times BEFORE moving to the next. A plain `streamText` uses only the primary.
299
+ - **Only a provider (`generation`) failure falls through.** A `decode` failure — the model answered but the output didn't satisfy the schema — surfaces immediately, because another provider won't fix a schema/prompt problem.
300
+ - **Streams fall through only before content flows.** Once any token/tool event has streamed the answer is committed and a later error surfaces as-is (re-running elsewhere would duplicate output); a deliberate `cancelled` never triggers a fallback.
301
+ - **No `fallbacks` ⇒ unchanged single-provider behavior.**
302
+
303
+ Typed errors are preserved end-to-end: a fully-exhausted chain fails with the last `AiError` on the Effect channel (generate) or a terminal `error` event (stream). Cost/usage is attributed to whichever provider actually served the call (the observability span + metrics stamp its `provider`/`model`).
304
+
305
+ ## Middleware — wrap every model call
306
+
307
+ Language-model **middleware** wraps every model the framework resolves — for `generateText`, `generateObject`, `streamText`, and agents alike — so you add cross-cutting behavior (logging, reasoning extraction, default settings, caching, guardrails) in ONE place without touching call sites. It's the AI SDK's `wrapLanguageModel` seam, exposed as a process-global stack you install once at boot:
308
+
309
+ ```ts
310
+ import { setAiMiddleware, loggingMiddleware } from '@voltro/ai'
311
+
312
+ // Typically in a *.startup.tsx boot hook (or app.config layers):
313
+ setAiMiddleware(loggingMiddleware())
314
+ ```
315
+
316
+ Every subsequent call is wrapped; nothing else changes. `setAiMiddleware(...)` returns the previous stack (restore it in a test), `getAiMiddleware()` reads it, `clearAiMiddleware()` empties it. With an empty stack the model is passed through untouched — zero overhead on the default path.
317
+
318
+ **Built-ins** (all re-exported from `@voltro/ai`):
319
+
320
+ - `loggingMiddleware({ log? })` — logs each call's model + token usage (dev default: `console`; pass `log` to route into your logger/metrics).
321
+ - `extractReasoningMiddleware({ tagName })` — split inline `<think>…</think>` reasoning out of the answer text into the reasoning channel, for models that don't emit native reasoning parts.
322
+ - `defaultSettingsMiddleware({ settings })` — pin default call settings (temperature, `maxOutputTokens`, `providerOptions`) for every call.
323
+ - `simulateStreamingMiddleware()` — make a generate-only model satisfy the streaming path (emits the full text as one delta).
324
+
325
+ **Custom middleware** is any object implementing `transformParams` / `wrapGenerate` / `wrapStream` (typed `AiMiddleware`):
326
+
327
+ ```ts
328
+ import { setAiMiddleware, type AiMiddleware } from '@voltro/ai'
329
+
330
+ const redactPII: AiMiddleware = {
331
+ transformParams: async ({ params }) => params, // scrub params.prompt before it leaves
332
+ }
333
+ setAiMiddleware(redactPII, loggingMiddleware()) // applied in order — the first entry is outermost
334
+ ```
335
+
336
+ Middleware is **server-only** — install it where the app boots, never from a browser-safe descriptor.
337
+
338
+ ## Embeddings — a separate axis
339
+
340
+ Embeddings use their own provider env, not `AI_PROVIDER`:
341
+
342
+ ```bash
343
+ AI_EMBED_PROVIDER=openai # mock (default) | openai | voyage | cohere
344
+ AI_EMBED_MODEL=text-embedding-3-small
345
+ ```
346
+
347
+ The default is `mock` (deterministic, key-free). Real embedding providers (`openai` / `voyage` / `cohere`) resolve their AI-SDK package via a lazy, server-only dynamic import — install the package (e.g. `@ai-sdk/openai`) to use them. See [RAG](/docs/ai/rag).
348
+
349
+ ## Provider quirks
350
+
351
+ - **Anthropic 429s** burst — burst rate limits trip before the monthly quota. Handle retries with `Effect.retry` in your executor.
352
+ - **Mock determinism** — every test using `useMockAi` is isolated; fixtures don't bleed across `describe` blocks.
353
+
354
+
355
+
356
+ ---
357
+
358
+ <!-- source: en/ai/agents.md -->
359
+ ## Agents
360
+
361
+ _`*.agent.tsx` files — definition, system prompts, tool wiring, streaming responses, and turn structure._
362
+
363
+ A Voltro **agent** is a server-side LLM workflow with a typed input, a system prompt, an optional tool list, and a streaming response. The file convention is `*.agent.tsx`.
364
+
365
+ Agents bridge two worlds: they're chat-completion-shaped (messages in, tokens out) but they live inside the framework's executor model — the synthesized `<name>.send` is an **action**, so it gets `ctx` and can do external I/O. (Actions are not transactional; an agent run is not rolled back.)
366
+
367
+ ## Defining an agent — descriptor + executor
368
+
369
+ Like queries/mutations, an agent is **two files paired by basename** — the browser/server boundary again:
370
+
371
+ - **`*.agent.tsx` — the descriptor** (`defineAgent` from `@voltro/ai/agent`): just `name` + `input` schema. Browser-safe, so codegen value-imports it into `rpcGroup.generated.ts` — the web client is typed **end-to-end** for the synthesized routes.
372
+ - **`*.agent.server.tsx` — the executor** (`defineAgentExecutor` from `@voltro/ai`, default-exported): system prompt, tools (which import server services), per-agent **model + API key**, and `maxSteps`. Server-only — secrets never reach the browser.
373
+
374
+ ```tsx
375
+ // apps/api/agents/support.agent.tsx — DESCRIPTOR (browser-safe)
376
+ import { defineAgent } from '@voltro/ai/agent'
377
+ import { Schema } from 'effect'
378
+
379
+ export const support = defineAgent({
380
+ name: 'support', // → support.send / support.messages
381
+ input: Schema.Struct({ prompt: Schema.String, plan: Schema.optional(Schema.String) }),
382
+ })
383
+ ```
384
+
385
+ ```tsx no-check
386
+ // apps/api/agents/support.agent.server.tsx — EXECUTOR (server-only)
387
+ import { defineAgentExecutor } from '@voltro/ai'
388
+ import { support } from './support.agent'
389
+ import { searchDocs } from '../tools/searchDocs.tool'
390
+
391
+ export default defineAgentExecutor(support, {
392
+ system: (input) => `You are a friendly support agent for the Voltro framework.
393
+ Be concise. The user is on the ${input.plan ?? 'free'} plan.`,
394
+ tools: { searchDocs },
395
+ model: { name: 'openai', model: 'gpt-5.5', apiKey: process.env.SUPPORT_OPENAI_KEY },
396
+ maxSteps: 8,
397
+ // Provider-specific knobs forwarded to the SDK — most importantly REASONING
398
+ // EFFORT (see "Reasoning effort" below). Pin it low for a routing/help agent.
399
+ providerOptions: { openai: { reasoningEffort: 'low' } },
400
+ })
401
+ ```
402
+
403
+ The framework pairs the two by basename and synthesizes, per agent `name`, two procedures:
404
+
405
+ - `<name>.send` — an **action** that appends the user turn + streams an assistant turn (delta-persisted to `agent_messages`). Wire input: the descriptor's `input` fields + `threadId` + `order`.
406
+ - `<name>.messages` — a **reactive query** (`source: 'agent_messages'`) that streams the persisted turns — including the live one being typed — to the browser.
407
+
408
+ **Codegen emits both routes into `rpcGroup.generated.ts`**, so `useAction` / `useSubscription` resolve them with full types. (Older code hand-wrote thread/send/list actions just to get a typed client — that's no longer needed; the agent path is the typed, supported way.)
409
+
410
+ `tools` is a `Record<string, AnyTool>` (the same shape `runAssistant` takes), keyed however you like — the tool's own `name` is what the model sees.
411
+
412
+ ## Per-agent model + key
413
+
414
+ `model` on the executor overrides the global default per agent — and carries its own key:
415
+
416
+ ```tsx
417
+ defineAgentExecutor(support, {
418
+ model: {
419
+ name: 'openai', // 'openai' | 'anthropic' | 'gateway' | 'mock'
420
+ model: 'gpt-5.5', // for gateway: a 'creator/model' id, e.g. 'openai/gpt-5.5'
421
+ apiKey: process.env.SUPPORT_OPENAI_KEY, // OPTIONAL — see fallback below
422
+ },
423
+ // …
424
+ })
425
+ ```
426
+
427
+ **Env is the fallback.** Each `model` field is optional:
428
+
429
+ - Omit `apiKey` → the provider reads its standard env var (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `AI_GATEWAY_API_KEY`).
430
+ - Omit `model` entirely → the agent inherits `AI_PROVIDER` / `AI_MODEL` from env (the global default).
431
+
432
+ So one agent can run GPT-5.5 on a dedicated key while another inherits the env default — no global-only constraint. The key lives in `*.agent.server.tsx`, which never reaches the browser.
433
+
434
+ ### Dynamic model — a function of the input (model picker / routing / BYOK)
435
+
436
+ `model` may also be a **function** of the decoded input (like `system`) — it returns a full `ProviderConfig` (incl. `apiKey`), runs server-side, and `undefined` falls back to env for that call. Two patterns:
437
+
438
+ **Model picker / routing** — the input selects among server-owned configs (UI picker, cost/size routing, plan tiers):
439
+
440
+ ```tsx
441
+ // descriptor: input: Schema.Struct({ prompt: Schema.String, tier: Schema.Literal('fast', 'smart') })
442
+ export default defineAgentExecutor(support, {
443
+ model: (input) => input.tier === 'smart'
444
+ ? { name: 'openai', model: 'gpt-5.5' }
445
+ : { name: 'openai', model: 'gpt-4o-mini' },
446
+ })
447
+ ```
448
+
449
+ **BYOK (bring your own key)** — the caller supplies their OWN key; you fold it into the returned config:
450
+
451
+ ```tsx
452
+ // descriptor: input: Schema.Struct({ prompt: Schema.String, apiKey: Schema.String })
453
+ export default defineAgentExecutor(support, {
454
+ model: (input) => ({ name: 'openai', model: 'gpt-5.5', apiKey: input.apiKey }),
455
+ })
456
+ ```
457
+
458
+ > **Security.** The input is client-controlled, so the real footgun is letting it pick an **expensive model on a key YOU pay for** — for a picker, constrain the choice with a `Schema.Literal` in the descriptor's `input` and map to **server-owned** configs (don't do `(input) => ({ model: input.model })`). **BYOK is the legitimate exception:** returning `apiKey: input.apiKey` is the whole point — the cost is on the user's key. The key is NOT persisted (only the prompt is stored in `agent_messages`); just make sure your logging/tracing doesn't capture it.
459
+
460
+ ## Reasoning effort + provider tuning (`providerOptions`)
461
+
462
+ `providerOptions` on the executor is forwarded verbatim to the underlying SDK call — the outer key is the provider id, the inner record its options. The headline use is **reasoning effort**: a reasoning model (`gpt-5.x`) defaults to a HIGH effort that spends many seconds "thinking" before the first token — even for a one-line answer. A navigation / help / Q&A assistant doesn't need that; pin it low (or `minimal`) for a dramatically faster first token at negligible quality cost:
463
+
464
+ ```tsx
465
+ export default defineAgentExecutor(support, {
466
+ // …
467
+ providerOptions: { openai: { reasoningEffort: 'low' } }, // or 'minimal'
468
+ // anthropic equivalent: { anthropic: { thinking: { type: 'disabled' } } }
469
+ })
470
+ ```
471
+
472
+ The same `providerOptions` field is accepted by the one-shot free functions too — `generateText`, `generateObject`, `generateObjectWithTools`, and `streamText` — for the same per-call provider tuning.
473
+
474
+ ## Conversation memory — automatic
475
+
476
+ The synthesized `<name>.send` sends the **whole persisted thread** to the model on every turn (the prior user/assistant turns + the new prompt), so the assistant remembers earlier messages. You don't thread history yourself — appending the user turn before the model call (which the synthesized send does) is enough. A custom `runAssistant` caller can pass an explicit `messages` array for the same effect; with none, it falls back to the single `prompt`.
477
+
478
+ ## Anatomy of an agent run
479
+
480
+ ```text
481
+ client → <name>.send({ threadId, prompt, order })
482
+
483
+ ▼ append user turn → runAssistant(store, { threadId, prompt, system, tools, order })
484
+
485
+ ▼ streamText runs the loop: model calls tool? → server runs tool → result back to model
486
+ │ (loop until done, up to maxSteps)
487
+
488
+ ▼ token/tool deltas throttle-patched onto the live agent_messages row
489
+
490
+ ▼ <name>.messages subscription re-fires → client renders the next chunk
491
+
492
+ ▼ done → streaming:false on the row
493
+ ```
494
+
495
+ Multiple rounds of tool calls are handled inside `runAssistant` / `streamText` — you don't loop manually unless you write a custom executor.
496
+
497
+ ## The system prompt
498
+
499
+ System prompts go in the agent definition's `system` field, NOT in the messages array. Why:
500
+
501
+ - It keeps the prompt out of the per-turn message history — the durable `agent_messages` rows hold the conversation, not the boilerplate instructions.
502
+ - The framework can apply prompt caching to it automatically (Anthropic supports cached system prompts).
503
+ - Versioning is easier — change the prompt, deploy, every call uses the new one.
504
+
505
+ The `system` field lives on the **executor**. Template it with per-call values (locale, persona, plan-specific instructions) — the function receives the input typed from the descriptor's schema:
506
+
507
+ ```tsx
508
+ // support.agent.server.tsx — `plan` comes from the descriptor's input schema
509
+ export default defineAgentExecutor(support, {
510
+ system: (input) => `You are speaking with a user on the ${input.plan ?? 'free'} plan.`,
511
+ // …
512
+ })
513
+ ```
514
+
515
+ The system function receives the typed input + can return a string. It's called fresh on every request.
516
+
517
+ ## Wiring tools
518
+
519
+ ```tsx
520
+ import { searchDocs } from '../tools/searchDocs.tool'
521
+ import { createTicket } from '../tools/createTicket.tool'
522
+
523
+ export const supportAgent = defineAgent({
524
+ name: 'support',
525
+ // …
526
+ tools: { searchDocs, createTicket },
527
+ })
528
+ ```
529
+
530
+ Tool definitions are typed — the agent doesn't need any string-keyed routing. The agent's input/output is Schema-validated; so is every tool call's input/output. See [Tools](/docs/ai/tools).
531
+
532
+ ## Turn structure — the zero-config default
533
+
534
+ For the persisted, reactive chat (what `defineAgent` synthesizes), the client subscribes to the agent's `<name>.messages` query and sends turns through its `<name>.send` action. No token-stream handling — tokens land as throttled patches on the live `agent_messages` row, which the subscription re-fires:
535
+
536
+ ```tsx
537
+ // client side — the durable pattern
538
+ const { data: messages = [] } = useSubscription('app', 'support.messages', { threadId })
539
+ const send = useAction('app', 'support.send')
540
+
541
+ const sendMessage = (prompt: string) =>
542
+ send.run({ threadId, prompt, order: messages.length })
543
+ ```
544
+
545
+ The server side is generated — you don't write the send action or the messages query when you declare a `defineAgent`. The thread tables (`agent_threads`, `agent_messages`) are auto-provided + auto-migrated.
546
+
547
+ ### Transient runs — `useAgent`
548
+
549
+ When you DON'T want persistence (an ephemeral playground, a one-off completion), wrap a `defineStream` rpc with the `useAgent` client hook — it derives `tokens` from the run's token events and accumulates `history`:
550
+
551
+ ```tsx
552
+ // client side — transient
553
+ const support = useAgent('app', 'support.run')
554
+
555
+ const sendMessage = (text: string) => {
556
+ support.send({ message: text, history: support.history })
557
+ // support.tokens streams in; on done it folds into support.history
558
+ }
559
+ ```
560
+
561
+ ### Fully custom run loop — `defineStream`
562
+
563
+ The agent path (descriptor + executor) is the default for the persisted, reactive chat. When you need a *fully custom* loop (mix conversation with bespoke side effects, your own persistence, a non-standard stream shape), don't try to bend the agent synthesis — write a `defineStream` rpc and drive it with the `streamText` free function, then consume it client-side with `useAgent` (transient) or persist deltas yourself:
564
+
565
+ ```tsx
566
+ // streams/thread.run.stream.ts (+ thread.run.stream.server.ts)
567
+ import { streamText } from '@voltro/ai'
568
+ import { Stream } from 'effect'
569
+
570
+ export default (input: { prompt: string }, ctx) =>
571
+ streamText({ prompt: input.prompt, system: 'You are concise.' }).pipe(
572
+ Stream.tap((event) => /* persist / forward `event` */ Stream.empty),
573
+ )
574
+ ```
575
+
576
+ ## Persisted, reactive chat (under the hood)
577
+
578
+ The durable pattern above is built on a delta-persistence layer that makes the conversation itself the durable, reactive record. Tokens reach the browser via a **reactive query**, not a raw socket.
579
+
580
+ Thread + message CRUD: `createThread`, `appendMessage`, `getMessages`, `getThread`. Streaming-turn helpers:
581
+
582
+ - `appendStreamingMessage(store, { threadId, tenantId?, order })` — insert one assistant row with `streaming: true` and empty `parts`; returns its id.
583
+ - `patchStreamingMessage(store, id, parts)` — patch the row's `parts` (mirrors text to `content`) as deltas arrive; throttle at the call site.
584
+ - `runAssistant(store, { threadId, tenantId?, prompt, system?, tools?, order, throttleMs? })` — does the whole turn: inserts the streaming row, consumes `streamText`, accumulates token deltas into a text part + records tool calls/results as tool parts, throttle-patches the row (default 100ms), then flips `streaming: false` with the final `parts`.
585
+
586
+ The pattern to feature:
587
+
588
+ 1. A **send action** appends the user message and calls `runAssistant`.
589
+ 2. A **reactive query with `source: 'agent_messages'`** streams the persisted rows — including the live `streaming: true` row being patched — to the browser. Each throttled patch mutates the row → the subscription re-fires → the client sees the next chunk.
590
+
591
+ ```tsx
592
+ // actions/chat.send.action.server.ts
593
+ import { appendMessage, runAssistant } from '@voltro/ai'
594
+ import { Effect } from 'effect'
595
+
596
+ export default (input: { threadId: string; text: string; order: number }, ctx) =>
597
+ Effect.gen(function* () {
598
+ yield* appendMessage(ctx.store, { threadId: input.threadId, role: 'user', content: input.text, order: input.order })
599
+ yield* runAssistant(ctx.store, { threadId: input.threadId, prompt: input.text, order: input.order + 1 })
600
+ return { ok: true }
601
+ })
602
+ ```
603
+
604
+ ```tsx
605
+ // queries/chat.messages.query.ts → source: 'agent_messages'
606
+ // queries/chat.messages.query.server.ts
607
+ import { getMessages } from '@voltro/ai'
608
+ export default async (input: { threadId: string }, ctx) => getMessages(ctx.store, input.threadId)
609
+ ```
610
+
611
+ ```tsx
612
+ // client — a normal subscription; no token-stream handling
613
+ const { data: messages } = useSubscription('app', 'chat.messages', { threadId })
614
+ const send = useAction('app', 'chat.send')
615
+ ```
616
+
617
+ The `agent_messages` row carries `streaming` (live typewriter flag), `order` (thread position), `stepOrder` (sub-position within a turn, for LLM↔tool steps), and `parts` (the structured `text` + `tool` payload the feed renders). Both `agent_threads` and `agent_messages` are tenant-scoped — the active org id is stamped on every row. Full worked example in [Streaming](/docs/ai/streaming#streaming-deltas-through-a-reactive-query-persisted-no-raw-socket).
618
+
619
+ ## Constraining responses (JSON, schema)
620
+
621
+ For non-chat agents where you want structured output:
622
+
623
+ ```tsx
624
+ import { Schema } from 'effect'
625
+
626
+ const SummarySchema = Schema.Struct({
627
+ title: Schema.String,
628
+ bullets: Schema.Array(Schema.String),
629
+ priority: Schema.Literal('low', 'medium', 'high'),
630
+ })
631
+
632
+ import { generateObject } from '@voltro/ai'
633
+
634
+ const { object: summary } = await Effect.runPromise(generateObject({
635
+ schema: SummarySchema,
636
+ system: 'Summarise the input into a JSON object.',
637
+ prompt: input.text,
638
+ }))
639
+ // summary is typed { title: string, bullets: string[], priority: 'low' | 'medium' | 'high' }
640
+ ```
641
+
642
+ `generateObject` converts the Effect Schema to JSON Schema for the provider, then decodes the model output back through the schema, so refinements/brands hold and malformed output surfaces as a typed `AiError({ reason: 'decode' })`.
643
+
644
+ `generateObject` is single-shot — it takes no tools. When a batch agent needs BOTH adaptive tool-calling (fetch detail on demand) AND a final schema-constrained object, use `generateObjectWithTools`:
645
+
646
+ ```tsx
647
+ import { generateObjectWithTools } from '@voltro/ai'
648
+
649
+ const { object: summary } = yield* generateObjectWithTools({
650
+ schema: SummarySchema,
651
+ system: 'Drill into the ticket with the tools, then summarise.',
652
+ prompt: input.text,
653
+ tools: { getIssueComments, getIssueChangelog }, // looped on demand
654
+ maxSteps: 16, // LLM↔tool round-trips, default 8
655
+ })
656
+ ```
657
+
658
+ It runs the LLM↔tool loop (`stopWhen` at `maxSteps`) and constrains the terminal answer to the schema, decoding it through the same Effect Schema as `generateObject`. The tools reach the caller's runtime services (see [Tools → Effect tool bodies reach app services](/docs/ai/tools#effect-tool-bodies-reach-app-services)).
659
+
660
+ ## Cancelling mid-stream
661
+
662
+ For the transient `useAgent` path, `cancel()` interrupts the in-flight run; the server-side stream scope tears down (which aborts the upstream model call):
663
+
664
+ ```tsx
665
+ const support = useAgent('app', 'support.run')
666
+ support.cancel() // interrupts the run; the server-side scope tears down
667
+ ```
668
+
669
+ ## When agents are the wrong tool
670
+
671
+ - **Single-shot summarisation / classification** — use an action with `generateText` / `generateObject`. Agents shine for multi-turn / tool-using flows.
672
+ - **Background processing** — use a workflow. Agents are request-scoped; workflows survive crashes + can run for hours.
673
+
674
+ See [RAG](/docs/ai/rag) for the canonical "agent + tool + vector search" pattern.
675
+
676
+
677
+
678
+ ---
679
+
680
+ <!-- source: en/ai/tools.md -->
681
+ ## Tools
682
+
683
+ _`*.tool.tsx` files — Schema-validated tool definitions agents can call, with side effects, retries, and tenant scoping._
684
+
685
+ A **tool** is a function the agent can call. It has a name, a description, a Schema-typed input + output, and a handler. The framework's discovery picks up every `*.tool.tsx` file; the agent runtime composes them into the model's tool list.
686
+
687
+ Tools are the bridge between the LLM ("I want to look up the user's plan") and your data ("here's the row from `users`").
688
+
689
+ ## Defining a tool
690
+
691
+ ```tsx
692
+ // apps/api/tools/searchDocs.tool.tsx
693
+ import { defineTool } from '@voltro/ai'
694
+ import { Schema } from 'effect'
695
+
696
+ export const searchDocs = defineTool({
697
+ name: 'search-docs',
698
+ description: 'Search Voltro documentation. Returns up to 5 results.',
699
+ input: Schema.Struct({ query: Schema.String }),
700
+ output: Schema.Array(Schema.Struct({
701
+ title: Schema.String,
702
+ snippet: Schema.String,
703
+ href: Schema.String,
704
+ })),
705
+ })
706
+
707
+ export default async ({ query }, ctx) => {
708
+ const rows = await ctx.store.select('docs')
709
+ .where('body', 'fts', query)
710
+ .limit(5)
711
+ .all()
712
+ // Project to the declared `output` shape. When `output` is set, the
713
+ // framework decodes the handler's return value through it before
714
+ // handing the result to the model — returning raw `docs` rows here
715
+ // would fail that decode.
716
+ return rows.map((r) => ({
717
+ title: r.title,
718
+ snippet: r.body.slice(0, 160),
719
+ href: `/docs/${r.id}`,
720
+ }))
721
+ }
722
+ ```
723
+
724
+ The export shape is the same as agents: a `defineTool({...})` config + a default-exported async handler.
725
+
726
+ ## How the agent uses it
727
+
728
+ Tools are wired on the agent's **executor** (`*.agent.server.tsx`) — they import server services, so they stay out of the browser:
729
+
730
+ ```tsx
731
+ // apps/api/agents/support.agent.server.tsx
732
+ import { defineAgentExecutor } from '@voltro/ai'
733
+ import { support } from './support.agent'
734
+ import { searchDocs } from '../tools/searchDocs.tool'
735
+
736
+ export default defineAgentExecutor(support, {
737
+ tools: { searchDocs },
738
+ })
739
+ ```
740
+
741
+ At call time, the framework:
742
+
743
+ 1. Translates each tool into the provider's native tool format (Anthropic's `tools` shape).
744
+ 2. Includes them in the chat-completion request.
745
+ 3. When the model returns a tool-call message, looks up the matching tool, validates the input against the Schema, runs the handler, validates the output, and feeds the result back to the model.
746
+ 4. Loops until the model returns a final text response (or hits `maxTurns`).
747
+
748
+ You don't write the tool-call loop. The framework does.
749
+
750
+ ## Description prompt engineering
751
+
752
+ The model's only signal for "when should I call this tool" is the `description`. Be specific:
753
+
754
+ ```ts
755
+ // BAD
756
+ description: 'Search docs.'
757
+
758
+ // GOOD
759
+ description: `Search the Voltro framework documentation. Use this when the
760
+ user asks how to use a feature, how to debug something, or what an API
761
+ does. Returns up to 5 results ranked by relevance.`
762
+ ```
763
+
764
+ Include:
765
+
766
+ - **When to call** — the situations this tool handles
767
+ - **What it returns** — shape + ranking hints
768
+ - **What it doesn't do** — sets boundaries against over-calling
769
+
770
+ For tools that should ONLY be called once per turn, say so in the description. The model usually listens.
771
+
772
+ ## Tool input validation
773
+
774
+ Schema validates the model's tool-call arguments before your handler sees them. Invalid inputs → the framework feeds an error back to the model + asks it to retry:
775
+
776
+ ```ts
777
+ input: Schema.Struct({
778
+ query: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200)),
779
+ limit: Schema.Number.pipe(Schema.between(1, 20)).pipe(Schema.optional),
780
+ })
781
+ ```
782
+
783
+ The model sees a structured "your input was invalid for these reasons" message + reformulates. The user never sees the failure — it's a model-internal retry.
784
+
785
+ ## Tools with side effects
786
+
787
+ Tools that write (`createTicket`, `bookMeeting`, `sendEmail`) get the same `ctx` as mutations:
788
+
789
+ ```tsx
790
+ export const createTicket = defineTool({
791
+ name: 'create-ticket',
792
+ description: 'Open a support ticket for the user.',
793
+ input: Schema.Struct({
794
+ subject: Schema.String,
795
+ body: Schema.String,
796
+ }),
797
+ output: Schema.Struct({ id: Schema.String }),
798
+ })
799
+
800
+ export default async (input, ctx) => {
801
+ if (ctx.subject.type !== 'user') {
802
+ throw new Error('Tool only available for signed-in users.')
803
+ }
804
+ const t = await ctx.store.insert('tickets', {
805
+ ...input,
806
+ userId: ctx.subject.id,
807
+ tenantId: ctx.subject.tenantId,
808
+ })
809
+ return { id: t.id }
810
+ }
811
+ ```
812
+
813
+ The tool's subject is the **calling agent's subject** — i.e. the user who invoked the agent. Tools cannot impersonate other users.
814
+
815
+ ## Tenant scoping
816
+
817
+ Tools inherit `ctx.subject.tenantId` from the agent's caller. Reads via `ctx.store` auto-scope to that tenant; writes need `assertOwnTenant`. Same rules as mutations.
818
+
819
+ The model cannot pass a different `tenantId` to escalate — even if it tries (a `system_prompt` injection attempt), the framework's tenant scope is enforced at the data layer, not the tool layer.
820
+
821
+ ## Effect tool bodies reach app services
822
+
823
+ A tool body written as an inline `execute(input) => Effect` may `yield*` any Effect service the **caller's runtime** provides — `JiraService`, `EffectStore`, `HttpClient`, your own `Context.Tag` layers. The loop primitives (`streamText`, `runAssistant`, `generateObjectWithTools`) capture the ambient runtime (`Effect.runtime`) and thread it into the tool-execution context, so a tool run inside a workflow step or action that already has the service in scope can call it directly:
824
+
825
+ ```tsx
826
+ import { defineTool } from '@voltro/ai'
827
+ import { JiraService } from '@voltro/plugin-atlassian'
828
+ import { Effect, Schema } from 'effect'
829
+
830
+ export const getIssueComments = defineTool({
831
+ name: 'getIssueComments',
832
+ description: 'Fetch all comments of a Jira issue.',
833
+ input: Schema.Struct({ jiraKey: Schema.String }),
834
+ output: Schema.Array(Schema.Struct({ author: Schema.NullOr(Schema.String), body: Schema.String })),
835
+ execute: ({ jiraKey }) =>
836
+ Effect.gen(function* () {
837
+ const jira = yield* JiraService // ← provided by the caller's runtime
838
+ const raw = yield* jira.getComments(jiraKey)
839
+ return raw.map((c) => ({ author: c.author?.displayName ?? null, body: c.body }))
840
+ }).pipe(Effect.catchAll(() => Effect.succeed([]))), // degrade → empty, never abort the loop
841
+ })
842
+ ```
843
+
844
+ Catch the service's failures inside the body (the body's error channel is `never`): an uncaught failure surfaces to the model as a tool error. Tools that close over their data instead (a pure `execute` over a pre-fetched payload, or a `(input, ctx)` handler writing via `ctx.store`) don't need the runtime at all — both forms work side by side.
845
+
846
+ ## Tools that call other tools
847
+
848
+ A tool can use another tool's handler internally:
849
+
850
+ ```tsx
851
+ import searchDocsHandler from '../tools/searchDocs.tool'
852
+
853
+ export default async (input, ctx) => {
854
+ const docs = await searchDocsHandler({ query: input.query }, ctx)
855
+ // …
856
+ }
857
+ ```
858
+
859
+ Avoid calling `streamText` / `generateText` from inside a tool body to spawn a nested agent — it's an easy way to build an accidental runaway loop (the model calls the tool, the tool runs another model that calls the tool again). The framework doesn't stop you, but for chained work, model the chain as a workflow and start it through its generated RPC boundary instead.
860
+
861
+ ## Tools as a wedge for testability
862
+
863
+ A tool's input + output are typed + Schema-validated. That makes them trivial to unit-test:
864
+
865
+ ```ts
866
+ import tool from './searchDocs.tool'
867
+ import { makeTestContext, mockStore } from '@voltro/testing'
868
+
869
+ test('searchDocs finds relevant docs', async () => {
870
+ const ctx = makeTestContext({
871
+ store: mockStore({ docs: [{ id: 'foo', title: 'Foo', body: 'bar baz' }] }),
872
+ })
873
+ const out = await tool({ query: 'bar' }, ctx)
874
+ expect(out).toEqual([{ title: 'Foo', snippet: 'bar baz', href: '/docs/foo' }])
875
+ })
876
+ ```
877
+
878
+ `makeTestContext` / `mockStore` come from **`@voltro/testing`** — a separate package you add as a devDependency per app (`pnpm --filter @my-app/api add -D @voltro/testing vitest`); see [Testing → Unit testing](/docs/testing/unit-testing).
879
+
880
+ For tests that exercise the model loop (not just the tool body), use `mockAi` / `useMockAi` from `@voltro/ai/test` to install a deterministic provider — see [Providers](/docs/ai/providers#mock-for-tests).
881
+
882
+ No agent involved, no model call. Just the tool's logic. This is the right unit boundary — agents are integration tests; tools are unit tests.
883
+
884
+ ## App-as-an-agent — expose existing procedures as tools
885
+
886
+ You don't have to hand-wrap every endpoint as a `*.tool.tsx`. A query /
887
+ mutation / action descriptor already IS a safe LLM-tool spec — typed input,
888
+ RBAC-scoped, validated, audited — so annotate it `exposeAsTool` and synthesize
889
+ the toolset with `appTools`. The synthesized tool runs the REAL handler under
890
+ the calling subject: **the agent can do nothing the subject couldn't** (no new
891
+ authorization path), by construction.
892
+
893
+ ```ts
894
+ // Opt a descriptor in (a description is REQUIRED — the model needs it):
895
+ export const listOrders = defineQuery({
896
+ name: 'orders.list', input: ListInput, output: Schema.Array(Order), source: 'orders',
897
+ exposeAsTool: { description: "List the current tenant's orders." },
898
+ })
899
+ export const createOrder = defineMutation({
900
+ name: 'orders.create', input: CreateInput, output: Order, target: { table: 'orders', op: 'insert' },
901
+ exposeAsTool: { description: 'Create an order.', confirm: true }, // writes confirm by default
902
+ })
903
+ ```
904
+
905
+ ```ts
906
+ // Server-side: synthesize + run. `entries` = { descriptor, invoke } bound to
907
+ // the request ctx (the serve layer provides them).
908
+ import { appTools, generateObjectWithTools } from '@voltro/ai'
909
+
910
+ const tools = appTools(entries, { allow: ['orders.*'], includeWrites: true })
911
+ const { object } = yield* generateObjectWithTools({ prompt, tools, schema: Result })
912
+ ```
913
+
914
+ Safety defaults (don't override blindly): **reads are included, writes are
915
+ opt-in** (`includeWrites: true`) and **confirm by default**. Put destructive
916
+ tags on `deny`. `exposeAsTool: true` alone does NOT expose — a tool with no
917
+ description is unusable; always use the object form. The annotations also
918
+ surface in the capability manifest, so a coding agent discovers what's
919
+ tool-exposable.
920
+
921
+ The end-user-facing counterpart is **`<AppAgent>`** (from `@voltro/web`) — a
922
+ "do it for me" chat whose ceiling is the logged-in subject's own permissions.
923
+ See [Schema-driven UI → Reactive components](/docs/ui/reactive-components).
924
+
925
+ ## Anti-patterns
926
+
927
+ - **Tools that take freeform JSON.** The model writes JSON poorly. Use Schema everywhere; let the framework reject bad inputs.
928
+ - **Tools without descriptions.** The model has nothing to go on — it'll either over-call (every turn) or never call.
929
+ - **Long-running tools.** Tool calls block the agent's turn. For anything > 5s, queue a workflow and return a handle the agent can poll.
930
+ - **Tools that throw on common errors.** A throw stops the agent. Return a typed error variant so the model can recover.
931
+
932
+
933
+
934
+ ---
935
+
936
+ <!-- source: en/ai/streaming.md -->
937
+ ## Streaming
938
+
939
+ _AI token streams with defineStream, useAgentStream, and streamText — plus cancel, retry, and resumable streams._
940
+
941
+ AI streaming in Voltro uses the same stream primitive as any other one-shot server-to-client feed:
942
+
943
+ - `*.stream.ts` declares the `defineStream` descriptor.
944
+ - `*.stream.server.ts` returns an Effect `Stream`.
945
+ - `useAgentStream` consumes the stream on the client.
946
+
947
+ For durable chat that survives reloads, do not stream raw tokens to React state. Persist token deltas into `agent_messages` and expose them through a reactive query.
948
+
949
+ ## Transient Stream
950
+
951
+ Descriptor:
952
+
953
+ ```ts
954
+ // apps/api/streams/support.run.stream.ts
955
+ import { AgentEvent } from '@voltro/ai/events' // browser-safe Schema entry — NOT '@voltro/ai' (server-only)
956
+ import { defineStream } from '@voltro/protocol'
957
+ import { Schema } from 'effect'
958
+
959
+ export const supportRun = defineStream({
960
+ name: 'support.run',
961
+ input: Schema.Struct({ message: Schema.String }),
962
+ element: AgentEvent,
963
+ })
964
+ ```
965
+
966
+ Server executor:
967
+
968
+ ```ts
969
+ // apps/api/streams/support.run.stream.server.ts
970
+ import { streamText } from '@voltro/ai'
971
+
972
+ export default (input: { message: string }) =>
973
+ streamText({
974
+ system: 'You are concise and helpful.',
975
+ prompt: input.message,
976
+ })
977
+ ```
978
+
979
+ `streamText` emits `AgentEvent` elements. The `_tag`s are: `token` (text delta), `reasoning` (the model's thinking, separate from the answer), `toolCall`, `toolResult`, `source` (a cited RAG/web source — `url` or `document` variant), `file` (an inline file the model produced, usually an image: `mediaType` + base64 `data`), `message`, `error` (carries `retryable`), and `done`. The stream NEVER fails — an upstream error arrives as a terminal `error` event.
980
+
981
+ ## Cancel, retry
982
+
983
+ A "Stop" button needs to end an in-flight run out of band. Pass a `streamId` and call `cancelStream(streamId)` from a separate action/mutation — it aborts the provider call (token spend stops) and the stream ends on a terminal, non-retryable `cancelled` error.
984
+
985
+ ```ts
986
+ import { streamText, cancelStream } from '@voltro/ai'
987
+
988
+ // executor — register the run under a client-chosen id
989
+ streamText({ prompt: input.message, streamId: input.streamId })
990
+
991
+ // a separate stop.action.server.ts
992
+ export default (input: { streamId: string }) => ({ cancelled: cancelStream(input.streamId) })
993
+ ```
994
+
995
+ The registry is **in-process**: `cancelStream` only aborts a stream running on the same node and returns `false` when the id isn't known locally. On a multi-replica deployment, route the cancel call to the node that owns the stream (sticky by `streamId`), or pair it with the resumable-stream store's `markDone` so other nodes stop tailing. Single-node dev/self-host needs nothing extra.
996
+
997
+ `streamText` also accepts an external `signal` (`AbortSignal`) and SDK-level `maxRetries` (retries the provider HTTP call before any bytes stream). For recovering from an immediate provider error, `streamTextWithRetry(options, { maxAttempts, backoffMs })` re-runs the stream — but ONLY while nothing has streamed yet, so it never duplicates tokens; once content flows, a later error is surfaced as-is.
998
+
999
+ ## Resumable Streams
1000
+
1001
+ A resumable stream survives a client disconnect: close the tab mid-answer, reopen, and the assistant keeps streaming from where it left off. `resumableStreamText` runs the model ONCE (the producer, as a daemon that outlives the connection) and persists every event to a store; every consumer replays past its cursor then tails to the end.
1002
+
1003
+ ```ts
1004
+ // support.run.stream.server.ts
1005
+ import { resumableStreamText, memoryResumableStreamStore } from '@voltro/ai'
1006
+
1007
+ const store = memoryResumableStreamStore() // single node — see below for multi-node
1008
+
1009
+ export default (input: { message: string; streamId: string; fromSeq?: number }) =>
1010
+ resumableStreamText({
1011
+ streamId: input.streamId,
1012
+ store,
1013
+ options: { prompt: input.message },
1014
+ fromSeq: input.fromSeq, // a reconnect passes the last `seq` it rendered
1015
+ })
1016
+ ```
1017
+
1018
+ It returns a `Stream<SeqEvent>` — each element is `{ seq, event }`. The descriptor declares `element: SeqEvent` (import the Schema from `@voltro/ai/events`, the browser-safe entry — the `@voltro/ai` root is server-only) and an optional `fromSeq` on its input.
1019
+
1020
+ On the client, `useResumableAgentStream('app', 'support.run')` does the rest: it unwraps each `SeqEvent` (so `.events` are the plain inner events), tracks the highest `seq`, and on a transport drop BEFORE the run's terminal event it auto-reconnects with `fromSeq` = the last seq it rendered (exponential backoff; the no-progress cap resets whenever a reconnect delivers a new event, so a long flaky stream survives any number of well-spaced drops). The server replays past the cursor, then continues — one seamless stream.
1021
+
1022
+ For multi-node deployments use `dataStoreResumableStreamStore(ctx.store)` — it persists to the framework's own database (`streamEventsTable` + `streamStateTable`, register them in your `database/index.ts`) and elects exactly ONE producer per `streamId` via an atomic claim, so only one node runs the model while every node's consumers tail the shared log. Sweep finished streams with `gcResumableStreams(store, { olderThan })`.
1023
+
1024
+ For the fastest path, `redisResumableStreamStore(redis, { ttlSeconds })` backs the log with a Redis LIST (`RPUSH`/`LRANGE`) plus a `SET … NX` producer claim — TTL evicts finished/abandoned streams without a sweep. `@voltro/ai` takes no Redis dependency; you inject a tiny `ResumableRedis` client (five methods: `setNx` / `rpush` / `lrange` / `set` / `exists`) adapting ioredis / node-redis. All three backends satisfy the same `ResumableStreamStore` interface, so they swap without touching the producer/consumer code.
1025
+
1026
+ ## Client
1027
+
1028
+ ```tsx
1029
+ import { useAgentStream } from '@voltro/client'
1030
+ import type { AgentEvent } from '@voltro/ai'
1031
+
1032
+ const support = useAgentStream<AgentEvent>('app', 'support.run')
1033
+ const text = support.events
1034
+ .filter((event) => event._tag === 'token')
1035
+ .map((event) => event.text)
1036
+ .join('')
1037
+
1038
+ return (
1039
+ <>
1040
+ <button
1041
+ disabled={support.status === 'streaming'}
1042
+ onClick={() => support.start({ message: prompt })}
1043
+ >
1044
+ Send
1045
+ </button>
1046
+ <button onClick={support.cancel}>Cancel</button>
1047
+ <pre>{text}</pre>
1048
+ </>
1049
+ )
1050
+ ```
1051
+
1052
+ `useAgent` is a convenience wrapper over `useAgentStream` for transient chat UIs. It derives `tokens` and `history` so you do not have to filter raw events yourself.
1053
+
1054
+ ## Durable Chat
1055
+
1056
+ A plain transient stream is request-scoped: a reload loses the in-flight text (use a resumable stream, above, if you only need reconnect-resume). For product chat you usually want the full DURABLE history too — persist the assistant turn and stream the persisted rows through a query:
1057
+
1058
+ 1. A send action appends the user message.
1059
+ 2. The action calls `runAssistant(...)`.
1060
+ 3. `runAssistant` inserts one `agent_messages` row with `streaming: true`.
1061
+ 4. Token/tool deltas patch that row.
1062
+ 5. A query with `source: 'agent_messages'` re-runs and updates the UI.
1063
+
1064
+ ```ts
1065
+ // actions/chat.send.action.server.ts
1066
+ import { appendMessage, getMessages, runAssistant } from '@voltro/ai'
1067
+ import { Effect } from 'effect'
1068
+
1069
+ export default (
1070
+ input: { threadId: string; text: string },
1071
+ ctx,
1072
+ ) =>
1073
+ Effect.gen(function* () {
1074
+ const existing = yield* getMessages(ctx.store, input.threadId)
1075
+ const order = existing.length
1076
+
1077
+ yield* appendMessage(ctx.store, {
1078
+ threadId: input.threadId,
1079
+ role: 'user',
1080
+ content: input.text,
1081
+ order,
1082
+ })
1083
+
1084
+ yield* runAssistant(ctx.store, {
1085
+ threadId: input.threadId,
1086
+ prompt: input.text,
1087
+ order: order + 1,
1088
+ })
1089
+
1090
+ return { ok: true }
1091
+ })
1092
+ ```
1093
+
1094
+ ```ts
1095
+ // queries/chat.messages.query.ts declares source: 'agent_messages'
1096
+ // queries/chat.messages.query.server.ts
1097
+ import { getMessages } from '@voltro/ai'
1098
+
1099
+ export default (input: { threadId: string }, ctx) =>
1100
+ getMessages(ctx.store, input.threadId)
1101
+ ```
1102
+
1103
+ ```tsx
1104
+ const { data: messages } = useSubscription('app', 'chat.messages', { threadId })
1105
+ const send = useAction('app', 'chat.send')
1106
+
1107
+ await send.run({ threadId, text })
1108
+ ```
1109
+
1110
+ This pattern survives reloads, works across tabs, and can be driven by workflows.
1111
+
1112
+ ## Stream vs Persisted Query
1113
+
1114
+ | Need | Use |
1115
+ |---|---|
1116
+ | Playground token stream | `defineStream` + `useAgentStream` |
1117
+ | Cancelable one-shot generation | `streamText({ streamId })` + `cancelStream` |
1118
+ | Reconnect-resume an in-flight stream | `resumableStreamText` + a stream store |
1119
+ | Chat history after reload | Action + `agent_messages` query |
1120
+ | Cross-tab live conversation | Action + `agent_messages` query |
1121
+ | Crash/retry semantics | Workflow patches persisted rows |
1122
+
1123
+ ## Anti-Patterns
1124
+
1125
+ - **Saving only the final text for chat.** Persist deltas so the UI can show live progress and survive reloads.
1126
+ - **Using `useAgentStream` for blocking calls.** Use an action with `generateText` or `generateObject`.
1127
+ - **Using streams as subscriptions.** If the data is durable state, expose it through a query.
1128
+
1129
+
1130
+
1131
+ ---
1132
+
1133
+ <!-- source: en/ai/rag.md -->
1134
+ ## RAG (retrieval-augmented generation)
1135
+
1136
+ _Vectors + the vectorEmbedding mixin + hybrid search + rerank — the canonical recipe for grounding agents in your data._
1137
+
1138
+ Retrieval-Augmented Generation: instead of hoping the model remembers your docs, you **retrieve** relevant passages at call-time + put them in the prompt. The model answers from what you handed it, not from training data.
1139
+
1140
+ Voltro's RAG primitives live in three places:
1141
+
1142
+ - **Storage** — vector columns + HNSW indexes ([Vector columns](/docs/database/vectors)). Index-accelerated on postgres; MariaDB runs the distance operators natively; the other dialects store vectors but fall back to a sequential scan.
1143
+ - **Embedding generation** — `embed(text)` from `@voltro/ai` + the `vectorEmbedding()` mixin
1144
+ - **Retrieval helpers** — `nearestNeighbours(...)`, `hybridSearch(...)` on `ctx.store`, `rerank(...)` from `@voltro/ai`
1145
+
1146
+ ## The minimal RAG pipeline
1147
+
1148
+ ```tsx
1149
+ // apps/api/database/docs.entity.ts
1150
+ import { table, id, text, vectorEmbedding } from '@voltro/database'
1151
+ import { tenant } from '@voltro/plugin-multitenancy'
1152
+
1153
+ export const docs = table('docs', {
1154
+ id: id(),
1155
+ body: text(),
1156
+ }).with(
1157
+ vectorEmbedding({
1158
+ from: 'body',
1159
+ model: 'text-embedding-3-small',
1160
+ dimensions: 1536,
1161
+ }),
1162
+ tenant(),
1163
+ )
1164
+ ```
1165
+
1166
+ The mixin:
1167
+
1168
+ - Adds an `embedding: vector(1536)` column with an HNSW index.
1169
+ - On INSERT, the runtime calls `embed(body)` (from `@voltro/ai`) + stores the vector.
1170
+ - On UPDATE of `body`, re-embeds.
1171
+
1172
+ You write `body`. The vector handles itself.
1173
+
1174
+ > **Embeddings default to `mock`.** Out of the box `embed` uses a deterministic, key-free mock provider (it hashes the text into a stable vector — reproducible, but not semantic). The `model: 'text-embedding-3-small'` string is stored but ignored by the mock. To get real embeddings, set `AI_EMBED_PROVIDER=openai` (or `voyage` / `cohere`) and install that provider's package (e.g. `@ai-sdk/openai`). See [Providers](/docs/ai/providers#embeddings-a-separate-axis).
1175
+
1176
+ ## Querying
1177
+
1178
+ ```tsx
1179
+ // apps/api/tools/searchDocs.tool.tsx
1180
+ import { defineTool } from '@voltro/ai'
1181
+ import { Schema } from 'effect'
1182
+
1183
+ export const searchDocs = defineTool({
1184
+ name: 'search-docs',
1185
+ description: 'Search the user knowledge base. Returns up to 5 passages.',
1186
+ input: Schema.Struct({ query: Schema.String }),
1187
+ output: Schema.Array(Schema.Struct({
1188
+ body: Schema.String,
1189
+ href: Schema.String,
1190
+ distance: Schema.Number,
1191
+ })),
1192
+ })
1193
+
1194
+ export default async ({ query }, ctx) => {
1195
+ const results = await ctx.store.select('docs')
1196
+ .nearestNeighbours(query, 5) // embeds `query`, limit 5
1197
+ .all()
1198
+
1199
+ return results.map((r) => ({
1200
+ body: r.body,
1201
+ href: `/docs/${r.id}`,
1202
+ // `distance` is a DISTANCE — lower = closer. Keep the runtime field
1203
+ // name so consumers don't sort it backwards (a "score" would imply
1204
+ // higher = better). Invert it explicitly if you want a similarity.
1205
+ distance: r.distance,
1206
+ }))
1207
+ }
1208
+ ```
1209
+
1210
+ That's it. The runtime:
1211
+
1212
+ 1. Embeds the `query` string (via `@voltro/ai`'s `embed`).
1213
+ 2. Runs `ORDER BY embedding <=> $1 LIMIT 5` (via the HNSW index on postgres; sequential scan elsewhere).
1214
+ 3. Returns rows with a `distance` field.
1215
+
1216
+ ## Wiring into an agent
1217
+
1218
+ ```tsx
1219
+ // apps/api/agents/help.agent.tsx — descriptor (browser-safe)
1220
+ import { defineAgent } from '@voltro/ai/agent'
1221
+ import { Schema } from 'effect'
1222
+
1223
+ export const help = defineAgent({ name: 'help', input: Schema.Struct({ prompt: Schema.String }) })
1224
+ ```
1225
+
1226
+ ```tsx
1227
+ // apps/api/agents/help.agent.server.tsx — executor (server-only)
1228
+ import { defineAgentExecutor } from '@voltro/ai'
1229
+ import { help } from './help.agent'
1230
+ import { searchDocs } from '../tools/searchDocs.tool'
1231
+
1232
+ export default defineAgentExecutor(help, {
1233
+ system: `You are a helpful support agent. When the user asks how to do
1234
+ something, ALWAYS call search-docs first to ground your answer in the
1235
+ docs. Then summarise + cite the relevant passage.`,
1236
+ tools: { searchDocs },
1237
+ })
1238
+ ```
1239
+
1240
+ The two agent files are all you write — the framework synthesizes `help.send` + `help.messages` (codegen-typed for the client). The model decides when to call `search-docs`; the system prompt nudges it strongly — "always call first" is usually enough.
1241
+
1242
+ ## Chunking strategy
1243
+
1244
+ Voltro doesn't ship a markdown chunker — that's app-specific. The right strategy depends on your data:
1245
+
1246
+ | Data | Chunk by | Why |
1247
+ |---|---|---|
1248
+ | Markdown docs | H2 sections (≤2k tokens each) | Self-contained units; preserves heading context. |
1249
+ | API references | Function / type | Each row IS the chunk. |
1250
+ | Long-form articles | Sliding window with 200-token overlap | Preserves cross-paragraph context. |
1251
+ | Code | File / function | Each row IS the chunk. |
1252
+ | Customer support tickets | Per-ticket | Each row IS the chunk. |
1253
+
1254
+ For markdown chunking, a 30-line helper is enough:
1255
+
1256
+ ```ts
1257
+ const chunkBySection = (md: string): string[] => {
1258
+ const out: string[] = []
1259
+ let current = ''
1260
+ for (const line of md.split('\n')) {
1261
+ if (line.startsWith('## ') && current) {
1262
+ out.push(current)
1263
+ current = line
1264
+ } else {
1265
+ current += '\n' + line
1266
+ }
1267
+ }
1268
+ if (current) out.push(current)
1269
+ return out
1270
+ }
1271
+ ```
1272
+
1273
+ Ingest:
1274
+
1275
+ ```ts
1276
+ for (const chunk of chunkBySection(md)) {
1277
+ await ctx.store.insert('docs', { body: chunk })
1278
+ // The vectorEmbedding mixin handles the embedding side-effect.
1279
+ }
1280
+ ```
1281
+
1282
+ ## Hybrid search
1283
+
1284
+ Pure vector similarity misses exact-match queries ("does X support Y" — the keyword "Y" is more reliable than its embedding). Combine vector + full-text:
1285
+
1286
+ ```ts
1287
+ import { hybridSearch } from '@voltro/database'
1288
+
1289
+ const results = await ctx.store.select('docs').use(hybridSearch({
1290
+ vector: { col: 'embedding', query },
1291
+ fts: { indexName: 'docsBody', query },
1292
+ alpha: 0.6, // 0 = pure FTS, 1 = pure vector
1293
+ })).limit(5).all()
1294
+ ```
1295
+
1296
+ The FTS clause narrows the candidate set; the vector clause ranks it, fused with Reciprocal Rank Fusion. For a knowledge base it routinely beats either alone — for technical docs, lean toward `alpha=0.4-0.6` (FTS-weighted).
1297
+
1298
+ ## Re-ranking
1299
+
1300
+ For top-shelf retrieval quality, run a **re-ranker** over the top-N hits. `rerank` ships in `@voltro/ai`:
1301
+
1302
+ ```ts
1303
+ import { rerank } from '@voltro/ai'
1304
+
1305
+ const candidates = await searchDocs(query, 20)
1306
+ const reranked = yield* rerank({
1307
+ query,
1308
+ documents: candidates,
1309
+ getText: (d) => d.body,
1310
+ provider: 'cohere',
1311
+ model: 'rerank-english-v3.0',
1312
+ topN: 5,
1313
+ })
1314
+ const top5 = reranked.map((r) => r.document)
1315
+ ```
1316
+
1317
+ Re-rankers are slower than vector search but much more accurate. Use the cheap vector search to narrow to ~20 candidates, then the rerank model to pick the top 5. Total latency: ~150-300ms vs. 50ms for vector-only. The default `provider: 'mock'` scores by lexical overlap — deterministic and key-free for tests; the `cohere` / `voyage` providers resolve their SDK lazily (install the provider package to use them).
1318
+
1319
+ ## Citing sources
1320
+
1321
+ The model needs source info in its context to cite:
1322
+
1323
+ ```ts
1324
+ const docs = await searchDocs(query, 5)
1325
+ const context = docs.map((d, i) => `[${i + 1}] ${d.body}\n(source: ${d.href})`).join('\n\n')
1326
+
1327
+ const messages = [
1328
+ { role: 'system', content: `Sources:\n${context}\n\nAnswer using ONLY these sources. Cite as [1], [2], etc.` },
1329
+ { role: 'user', content: input.question },
1330
+ ]
1331
+ ```
1332
+
1333
+ For UI that links each citation: parse `[1]`, `[2]` patterns out of the model's output + map back to `docs[0].href`, `docs[1].href`. The agent SDK doesn't do this automatically — it's render-layer work.
1334
+
1335
+ ## Tenant isolation
1336
+
1337
+ The `vectorEmbedding()` mixin + `tenant()` mixin compose correctly:
1338
+
1339
+ ```ts
1340
+ const docs = table('docs', {
1341
+ id: id(),
1342
+ body: text(),
1343
+ }).with(
1344
+ vectorEmbedding({ from: 'body', dimensions: 1536 }),
1345
+ tenant(),
1346
+ )
1347
+ ```
1348
+
1349
+ Searches across `docs` are **automatically tenant-scoped**. The runtime AND-merges the tenant filter before the ANN order/limit, so Tenant A's queries never surface Tenant B's vectors — even though the vectors live in the same column.
1350
+
1351
+ ## Cost considerations
1352
+
1353
+ Embedding cost (only with a REAL provider configured — the default `mock` provider is free and offline):
1354
+
1355
+ - `text-embedding-3-small` (OpenAI, `AI_EMBED_PROVIDER=openai`): roughly $0.02 per 1M tokens.
1356
+ - `voyage-3` (`AI_EMBED_PROVIDER=voyage`): roughly $0.12 per 1M tokens.
1357
+
1358
+ Check the provider's current pricing — these are rough figures.
1359
+
1360
+ Storage cost:
1361
+
1362
+ - 1536-dim float32: ~6KB/row + ~3KB HNSW overhead = ~9KB/row.
1363
+ - 10k docs: ~100MB. Cheap.
1364
+ - 1M docs: ~10GB. Plan around it.
1365
+
1366
+ Re-embedding cost (rebuilding the column with a new model) = full corpus × embedding cost. Pick your model + dimensions deliberately.
1367
+
1368
+ ## When NOT to use RAG
1369
+
1370
+ - **The data fits in the context window.** If you have 50 docs and Claude can hold 200k tokens, just stuff them all in. Simpler, more accurate.
1371
+ - **The data IS the prompt.** For "translate this paragraph", you don't need retrieval.
1372
+ - **You need exact lookups.** RAG returns "similar" results; if you need "exactly this customer's order", use a regular query.
1373
+
1374
+ RAG is for when the corpus is too big for context + the answer is in a small slice of it.
1375
+
1376
+
1377
+
1378
+ ---
1379
+
1380
+ <!-- source: en/ai/cost-tracking.md -->
1381
+ ## Cost tracking
1382
+
1383
+ _Token usage on every call plus the shipped cost toolkit — estimateCostUsd + a price table, the _voltro_ai_usage ledger, spend sums, and per-tenant budget guards._
1384
+
1385
+ LLMs are usage-priced, so you need to know how many tokens each call burned. Every `@voltro/ai` call returns a **token usage tally** on its result. That's the shipped primitive.
1386
+
1387
+ On top of the raw tally, `@voltro/ai` ships a cost toolkit: a price table + `estimateCostUsd`, a reactive `_voltro_ai_usage` ledger (`recordAiUsage`), spend sums (`aiSpendUsd`), and a per-tenant budget guard (`requireAiBudget` → typed `AiBudgetExceeded`). (`@voltro/plugin-audit` is separate — it records **mutation invocations**, not AI calls.)
1388
+
1389
+ ## Token usage on every call
1390
+
1391
+ `generateText` and `generateObject` return `usage` alongside the result:
1392
+
1393
+ ```ts
1394
+ import { generateText } from '@voltro/ai'
1395
+ import { Effect } from 'effect'
1396
+
1397
+ export default (input: { prompt: string }) =>
1398
+ Effect.gen(function* () {
1399
+ const { text, usage } = yield* generateText({ prompt: input.prompt })
1400
+ // usage = { inputTokens, outputTokens, totalTokens }
1401
+ // each is `number | undefined` (provider-reported)
1402
+ return { text, tokens: usage.totalTokens }
1403
+ })
1404
+ ```
1405
+
1406
+ ```ts
1407
+ const { object, usage } = yield* generateObject({ prompt, schema })
1408
+ // same usage shape
1409
+ ```
1410
+
1411
+ ## Token usage on a streamed run
1412
+
1413
+ A `streamText` run ends with a terminal `done` event that carries the same usage shape:
1414
+
1415
+ ```ts
1416
+ import { streamText } from '@voltro/ai'
1417
+ import { Stream, Effect } from 'effect'
1418
+
1419
+ yield* streamText({ prompt }).pipe(
1420
+ Stream.runForEach((event) =>
1421
+ Effect.sync(() => {
1422
+ if (event._tag === 'done') {
1423
+ // event.finishReason — 'stop' | 'tool-calls' | 'error' | …
1424
+ // event.usage = { inputTokens, outputTokens, totalTokens }
1425
+ }
1426
+ }),
1427
+ ),
1428
+ )
1429
+ ```
1430
+
1431
+ The `done` event's `usage` is the run total across every LLM↔tool round-trip.
1432
+
1433
+ ## Pricing a call — `estimateCostUsd`
1434
+
1435
+ `estimateCostUsd(usage, { model })` turns a token tally into a USD
1436
+ `CostBreakdown` using the built-in price table (`MODEL_PRICING_DEFAULTS`, USD
1437
+ per 1M tokens). An unknown model — or the `mock` provider — prices at
1438
+ **zero**, so cost accounting never breaks a call.
1439
+
1440
+ The defaults cover the major providers — Anthropic (`claude-*`), OpenAI
1441
+ (`gpt-*`), and Google Gemini (`gemini-*`) — so a non-Claude call is priced too
1442
+ (a `gpt-4o` or `gemini-2.5-pro` call is a real number, not a silent zero). A
1443
+ gateway `creator/model` id (`openai/gpt-4o`) prices by its bare model segment.
1444
+
1445
+ ```ts
1446
+ import { generateText, estimateCostUsd } from '@voltro/ai'
1447
+
1448
+ const model = 'claude-opus-4-8'
1449
+ const { text, usage } = yield* generateText({ prompt, provider: { name: 'anthropic', model } })
1450
+ const cost = estimateCostUsd(usage, { model })
1451
+ // cost = { inputTokens, outputTokens, inputCostUsd, outputCostUsd, totalCostUsd, costSource }
1452
+ // costSource: 'estimated' (price-table or zero) | 'gateway' (real reported cost)
1453
+ ```
1454
+
1455
+ Override the price for a model the table doesn't know, or for
1456
+ negotiated / volume pricing:
1457
+
1458
+ ```ts
1459
+ estimateCostUsd(usage, { model, price: { inputPer1M: 2.5, outputPer1M: 10 } })
1460
+ ```
1461
+
1462
+ ### The built-in prices are point-in-time defaults — override them app-wide
1463
+
1464
+ `MODEL_PRICING_DEFAULTS` are public **list prices as of January 2026** and
1465
+ **WILL drift** as providers re-price. Treat them as a sane default for the
1466
+ budget guard + cost dashboard, not a contract. To encode current or negotiated
1467
+ rates once, at boot, without editing the framework, call `setModelPricing` — a
1468
+ process-global override map merged OVER the defaults (a user entry for a model
1469
+ id wins):
1470
+
1471
+ ```ts
1472
+ import { setModelPricing } from '@voltro/ai'
1473
+
1474
+ // Wire your ai config's `pricing` map through this at boot.
1475
+ setModelPricing({
1476
+ 'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10 }, // corrected list price
1477
+ 'my-tuned-model': { inputPer1M: 0.8, outputPer1M: 2.4 }, // a model the defaults don't know
1478
+ })
1479
+ ```
1480
+
1481
+ Every `estimateCostUsd` / `recordAiUsage` / budget call then reads the merged
1482
+ map. `priceForModel(model)` returns the effective price (or `undefined` if
1483
+ unknown). For a gateway-routed model, the gateway's REPORTED per-call cost still
1484
+ wins over any static rate (see below).
1485
+
1486
+ ## Real gateway cost — `gatewayCostUsd` + `actualCostUsd`
1487
+
1488
+ The static price table only knows the models it lists. A **gateway**-routed
1489
+ model the table doesn't carry would otherwise estimate to **zero** — wrong,
1490
+ not just imprecise. The fix: the Vercel AI Gateway reports the ACTUAL
1491
+ per-call cost in the result's provider metadata, and the toolkit prefers it.
1492
+
1493
+ `gatewayCostUsd(providerMetadata)` pulls `providerMetadata.gateway.cost` (a
1494
+ USD number or numeric string) out of a generate/stream result, returning
1495
+ `undefined` for a direct provider / the mock (so you fall back to the table):
1496
+
1497
+ ```ts
1498
+ import { generateText, gatewayCostUsd, recordAiUsage } from '@voltro/ai'
1499
+
1500
+ const model = 'openai/gpt-5.5' // a gateway id the static table doesn't list
1501
+ const r = yield* generateText({ prompt, provider: { name: 'gateway', model } })
1502
+ const actualCostUsd = gatewayCostUsd(r.providerMetadata) // the gateway's real charge, or undefined
1503
+
1504
+ const cost = yield* recordAiUsage(ctx.store, {
1505
+ tenantId: ctx.request.subject.tenantId,
1506
+ provider: 'gateway',
1507
+ model,
1508
+ operation: 'generateText',
1509
+ usage: r.usage,
1510
+ actualCostUsd, // when set → persisted verbatim, costSource: 'gateway'
1511
+ })
1512
+ // cost.costSource === 'gateway' (authoritative) when actualCostUsd was present,
1513
+ // else 'estimated' (price table or zero).
1514
+ ```
1515
+
1516
+ `estimateCostUsd(usage, { model, actualCostUsd })` honours the same rule: a
1517
+ present `actualCostUsd` wins (split across input/output by token share for
1518
+ the breakdown, `costSource: 'gateway'`); absent, it uses the price table
1519
+ (`costSource: 'estimated'`). A cost dashboard can flag `estimated` rows and
1520
+ un-priced (zero) models so you know which numbers are real vs derived.
1521
+
1522
+ ## The usage ledger — `recordAiUsage` + `aiUsageTable`
1523
+
1524
+ `recordAiUsage(store, {...})` prices a call and writes one row to the
1525
+ `_voltro_ai_usage` ledger (`aiUsageTable`), returning the same
1526
+ `CostBreakdown`. The table is **reactive** and auto-migrated whenever the
1527
+ app ships any `*.agent.tsx`; a non-agent app that wants plain-call
1528
+ tracking imports `aiUsageTable` into its `database/index.ts` barrel. Cost
1529
+ is stored as integer **micro-USD** (`costMicroUsd`, USD × 1e6) — the same
1530
+ "money = integer minor units" rule the billing plugin uses.
1531
+
1532
+ ```ts
1533
+ import { generateText, recordAiUsage } from '@voltro/ai'
1534
+
1535
+ const model = 'claude-opus-4-8'
1536
+ const { text, usage } = yield* generateText({ prompt, provider: { name: 'anthropic', model } })
1537
+ const cost = yield* recordAiUsage(ctx.store, {
1538
+ tenantId: ctx.request.subject.tenantId,
1539
+ provider: 'anthropic',
1540
+ model,
1541
+ operation: 'generateText', // or 'generateObject' | 'streamText' | 'agent' | your own
1542
+ usage,
1543
+ })
1544
+ // cost.totalCostUsd — surface it without a re-query
1545
+ ```
1546
+
1547
+ A row carries `{ tenantId, provider, model, operation, agent, inputTokens,
1548
+ outputTokens, costMicroUsd, costSource, calledAt }`. `costSource` is
1549
+ `'gateway'` (the actual reported cost) or `'estimated'` (price-table / zero) —
1550
+ pass `actualCostUsd` (see above) to record the real gateway charge.
1551
+
1552
+ ## Spend + budgets — `aiSpendUsd` / `requireAiBudget`
1553
+
1554
+ `aiSpendUsd(store, { tenantId?, since? })` sums recorded spend (USD).
1555
+ Because `aiUsageTable` is reactive, a `defineQuery` with
1556
+ `source: '_voltro_ai_usage'` that calls it is a **live spend meter** — the
1557
+ same reactive-query machinery as everything else.
1558
+
1559
+ ```ts
1560
+ const spentThisMonth = yield* aiSpendUsd(ctx.store, {
1561
+ tenantId: ctx.request.subject.tenantId,
1562
+ since: startOfMonth(),
1563
+ })
1564
+ ```
1565
+
1566
+ `requireAiBudget(store, { tenantId, limitUsd, addUsd?, since? })` gates a
1567
+ call against a per-tenant cap — the precedent is billing's
1568
+ `requireEntitlement`. It fails with a typed, client-marshalable
1569
+ `AiBudgetExceeded` when `reserved + addUsd` would exceed `limitUsd`. Call it
1570
+ BEFORE the provider call (estimate `addUsd` from the prompt); record the
1571
+ real cost after.
1572
+
1573
+ **Atomic across replicas — a hard cap, not a soft one.** The guard RESERVES
1574
+ `addUsd` on a single per-tenant counter row (`_voltro_ai_budget`) via a bounded
1575
+ compare-and-set loop — the same store-level atomic-consume the storage and
1576
+ billing plugins use. So N concurrent calls (same replica or across replicas)
1577
+ reserve **exactly** the budgeted amount and the rest fail — no overshoot. (This
1578
+ replaces an older check-then-act sum that concurrent callers could all read
1579
+ under-cap and all pass.) The reservation is optimistic: it does NOT auto-release
1580
+ if the provider call later fails, which for a rolling budget is the correct
1581
+ conservative bound. Pass `addUsd: 0` for a check-only UI pre-flight that reserves
1582
+ nothing. A rolling `since` window rotates to a fresh counter (the prior window's
1583
+ row ages out via retention).
1584
+
1585
+ ```ts
1586
+ import { generateText, requireAiBudget, recordAiUsage, AiBudgetExceeded } from '@voltro/ai'
1587
+ import { Effect } from 'effect'
1588
+
1589
+ export default (input: { prompt: string }, ctx) =>
1590
+ Effect.gen(function* () {
1591
+ const tenantId = ctx.request.subject.tenantId
1592
+ // Refuse if this tenant is already at/over its monthly cap.
1593
+ yield* requireAiBudget(ctx.store, { tenantId, limitUsd: 50, addUsd: 0.25, since: startOfMonth() })
1594
+
1595
+ const model = 'claude-opus-4-8'
1596
+ const { text, usage } = yield* generateText({ prompt: input.prompt, provider: { name: 'anthropic', model } })
1597
+ yield* recordAiUsage(ctx.store, { tenantId, provider: 'anthropic', model, operation: 'generateText', usage })
1598
+ return { text }
1599
+ })
1600
+ ```
1601
+
1602
+ Declare `error: AiBudgetExceeded` on the descriptor so the rpc layer
1603
+ surfaces the rejection typed; the client pattern-matches on
1604
+ `{ _tag: 'AiBudgetExceeded', limitUsd, spentUsd, attemptedUsd }` (`spentUsd` is
1605
+ the amount already reserved on the counter).
1606
+
1607
+ ## Per-call observability — automatic spans + metrics
1608
+
1609
+ Every `generateText` / `generateObject` / `generateObjectWithTools` /
1610
+ `streamText` call is automatically wrapped in a **`voltro.ai.call`** OTel span
1611
+ (attributes `ai.provider` / `ai.model` / `ai.operation`) and records metrics into
1612
+ the framework's global metric registry — the same one
1613
+ [`@voltro/plugin-prometheus`](/docs/plugins/prometheus) exposes at `/metrics` and
1614
+ the dashboard reads at `/_voltro/inspect/metrics`. No wiring needed:
1615
+
1616
+ | Metric | Type | What |
1617
+ | --- | --- | --- |
1618
+ | `voltro_ai_calls_total` | counter | Calls, labelled `provider` / `model` / `operation` / `status`. |
1619
+ | `voltro_ai_call_errors_total` | counter | Calls that errored. |
1620
+ | `voltro_ai_call_duration_seconds` | histogram | Provider call latency. |
1621
+ | `voltro_ai_input_tokens_total` / `voltro_ai_output_tokens_total` | counter | Prompt / completion tokens. |
1622
+ | `voltro_ai_cost_microusd_total` | counter | Estimated spend (micro-USD), priced off the merged table. |
1623
+
1624
+ Labels carry only provider / model / operation ids — never prompt or response
1625
+ content, never a key. Cost here is the *estimated* figure from the price table
1626
+ (for a live-priced budget cap, use `requireAiBudget`; for the authoritative
1627
+ gateway charge, use `recordAiUsage({ actualCostUsd })`).
1628
+
1629
+ ## Deliberately your call
1630
+
1631
+ The toolkit prices + records + gates; a few things stay explicit by design:
1632
+
1633
+ - **Recording the LEDGER is opt-in per call.** The free functions
1634
+ (`generateText` etc.) have no store or tenant, so they can't self-write the
1635
+ `_voltro_ai_usage` row — call `recordAiUsage` where you have `ctx` (an agent
1636
+ send handler is the natural spot). The metrics above ARE automatic; the durable
1637
+ per-row ledger is the opt-in part.
1638
+ - **The built-in prices are point-in-time list prices.** Call `setModelPricing`
1639
+ to override app-wide, pass `price` per call for negotiated rates, or
1640
+ `actualCostUsd` (from `gatewayCostUsd`) for the gateway's real per-call charge.
1641
+
1642
+ For quota tied to billing TIERS (not a raw USD cap), see
1643
+ [`@voltro/plugin-billing`](/docs/plugins/billing)'s entitlements —
1644
+ `requireEntitlement(ctx, 'aiCalls', n)`.
1645
+
1646
+
1647
+
1648
+ ---
1649
+
1650
+ <!-- source: en/ai/data-copilot.md -->
1651
+ ## Data copilot
1652
+
1653
+ _Natural-language questions over your data — the model proposes a query, every table/column is validated against a manifest (hallucinated names are refused, not executed), and the validated read-only descriptor runs AS the calling subject so tenant + row scoping always apply._
1654
+
1655
+ The data copilot turns a **natural-language question** into a **validated,
1656
+ read-only query** and returns the rows — without ever trusting the model with
1657
+ your schema. The model proposes a query shape; the framework **validates every
1658
+ table and column against a manifest** and refuses anything it doesn't recognise
1659
+ (a hallucinated column is a typed rejection, never a blind `WHERE`). The
1660
+ validated descriptor is a plain `SELECT` that runs **as the calling subject**, so
1661
+ tenant and row scoping apply exactly as they do for any other query.
1662
+
1663
+ > **Read-only in v1.** The copilot only ever reads. Trust hinges on validation —
1664
+ > one hallucinated number burns it — so schema-validation is non-negotiable, not
1665
+ > a nicety.
1666
+
1667
+ ## Server: the `copilot.ask` action
1668
+
1669
+ `@voltro/ai`'s `runDataCopilot(question, schema, { propose })` builds the grammar
1670
+ prompt, asks the model for a constrained proposal, and validates it against your
1671
+ `CopilotSchema`. It returns either a read-only `descriptor` or a typed
1672
+ `CopilotRejected`. You run the descriptor as the subject and shape the answer:
1673
+
1674
+ ```ts
1675
+ // copilot.ask.action.ts — the browser-safe descriptor
1676
+ import { defineAction } from '@voltro/protocol'
1677
+ import { Schema } from 'effect'
1678
+
1679
+ export const ask = defineAction({
1680
+ name: 'copilot.ask',
1681
+ input: Schema.Struct({ question: Schema.String }),
1682
+ output: Schema.Union(
1683
+ Schema.Struct({ ok: Schema.Literal(true), rows: Schema.Array(Schema.Record({ key: Schema.String, value: Schema.Unknown })) }),
1684
+ Schema.Struct({ ok: Schema.Literal(false), reason: Schema.String }),
1685
+ ),
1686
+ })
1687
+ ```
1688
+
1689
+ ```ts
1690
+ // copilot.ask.action.server.ts — the server executor (imports @voltro/ai)
1691
+ import { Effect } from 'effect'
1692
+ import { runDataCopilot, generateObject, CopilotProposalSchema, type CopilotSchema } from '@voltro/ai'
1693
+
1694
+ // The manifest the model is constrained to — only these tables/columns exist.
1695
+ const schema: CopilotSchema = {
1696
+ tables: [{ name: 'todos', columns: [
1697
+ { name: 'id', type: 'string' }, { name: 'title', type: 'string' },
1698
+ { name: 'done', type: 'boolean' }, { name: 'dueAt', type: 'date' },
1699
+ ] }],
1700
+ }
1701
+
1702
+ const execute = (input: { question: string }, ctx: AppContext) =>
1703
+ Effect.gen(function* () {
1704
+ const v = yield* Effect.promise(() =>
1705
+ runDataCopilot(input.question, schema, {
1706
+ propose: ({ system, prompt }) =>
1707
+ Effect.runPromise(
1708
+ generateObject({ system, prompt, schema: CopilotProposalSchema }).pipe(Effect.map((r) => r.object)),
1709
+ ),
1710
+ }),
1711
+ )
1712
+ if (!v.ok) return { ok: false as const, reason: v.rejection.reason }
1713
+ // The validated SELECT runs AS the subject → tenant + row scope apply.
1714
+ const rows = yield* Effect.promise(() => ctx.store.query(v.descriptor as never))
1715
+ return { ok: true as const, rows: rows as ReadonlyArray<Record<string, unknown>> }
1716
+ })
1717
+
1718
+ export default execute
1719
+ ```
1720
+
1721
+ ## Client: `useDataCopilot` + `<DataCopilot>`
1722
+
1723
+ The hook is a thin binding over `useAction` — it imports **nothing** from
1724
+ `@voltro/ai` (server-only), so the copilot engine never reaches the browser
1725
+ bundle:
1726
+
1727
+ ```tsx
1728
+ import { useDataCopilot } from '@voltro/client'
1729
+
1730
+ const copilot = useDataCopilot('app', 'copilot.ask')
1731
+ await copilot.ask('how many open todos are due this week?')
1732
+ // copilot.answer: { ok: true, rows } | { ok: false, reason }
1733
+ // copilot.pending, copilot.error, copilot.reset()
1734
+ ```
1735
+
1736
+ Or drop in the headless `<DataCopilot>` component (a prompt box + the
1737
+ refusal-or-rows answer), styled with `data-voltro-*` hooks:
1738
+
1739
+ ```tsx
1740
+ import { DataCopilot } from '@voltro/ui'
1741
+
1742
+ <DataCopilot api="app" action="copilot.ask" placeholder="Ask about your data…" />
1743
+ ```
1744
+
1745
+ ## What makes it safe
1746
+
1747
+ - **Schema-constrained.** Every proposed table, projection column, filter column,
1748
+ order column, operator, and aggregate is checked against the manifest. Unknown
1749
+ → a typed `CopilotRejected` (`unknown-table` / `unknown-column` / …), never an
1750
+ executed query.
1751
+ - **Read-only.** The descriptor is always a `SELECT`; the copilot can't write.
1752
+ - **Runs as the subject.** Tenant scope + row visibility apply because the
1753
+ descriptor runs through the same store path as any handler — a caller in tenant
1754
+ A never sees tenant B's rows.
1755
+ - **Bounded.** The row count is capped (`COPILOT_MAX_LIMIT`) so a question can't
1756
+ pull the whole table.
1757
+
1758
+
1759
+
1760
+ ---
1761
+
1762
+ <!-- source: en/ai/app-builder.md -->
1763
+ ## AI app-builder
1764
+
1765
+ _Turn a natural-language prompt into a real app — graph + files — gated by the framework's own `voltro check` (errors-as-LLM-API), never auto-written. Drive it from the CLI (`voltro generate`) or the cloud dashboard, with an in-browser live preview of the result._
1766
+
1767
+ The app-builder turns a **natural-language prompt** into a working app — the app
1768
+ GRAPH (tables + procedures + routes) plus the FILES that realise it — and it does
1769
+ so **without ever trusting the model to be right**. Every candidate is run through
1770
+ the framework's own `voltro check`; a structurally-invalid proposal is re-prompted
1771
+ with its typed diagnostics and **never written**. Nothing hits your tree (or
1772
+ deploys) without an explicit accept.
1773
+
1774
+ ## The loop (errors-as-LLM-API)
1775
+
1776
+ ```
1777
+ prompt ─▶ model proposes { graph, artifacts } ─▶ voltro check(graph)
1778
+ ▲ │
1779
+ └────── diagnostics fed back ◀── fails ──┤
1780
+ └── passes ─▶ proposal
1781
+ ```
1782
+
1783
+ The model only ever sees the framework's **capability grammar** (the primitives +
1784
+ your existing tables/procedures), and the real allow-list is `runCheck` — so a
1785
+ hallucinated table or an unbound route is caught and corrected, not shipped. After
1786
+ a bounded number of failed rounds the run is rejected; either way nothing is
1787
+ written.
1788
+
1789
+ ## From the CLI — `voltro generate`
1790
+
1791
+ ```bash
1792
+ voltro generate "add a comments table with a list + create" # dry-run: prints the proposal
1793
+ voltro generate "add a comments table with a list + create" --write # applies the accepted artifacts
1794
+ ```
1795
+
1796
+ Reads the committed capability manifest (`app.manifest.generated.json`) as the
1797
+ grammar, gates every candidate through `voltro check`, and writes **only** an
1798
+ accepted proposal **only** with `--write` (dry-run by default — the guardrail).
1799
+ See [Scaffolding](/docs/cli/scaffolding#generate-ai-app-builder) for the full flag
1800
+ list.
1801
+
1802
+ ## From the cloud dashboard
1803
+
1804
+ The dashboard's **`/builder`** panel drives the `apps.generateAppGraph` action,
1805
+ which runs the same loop server-side and returns a **proposal for review** — it
1806
+ never writes or deploys. The whole surface is behind the **`aiBuilder` feature
1807
+ flag** (off by default → a typed `FlagDisabled`), so you opt projects in
1808
+ deliberately. A model provider (`AI_PROVIDER` / `AI_MODEL` + key) backs the
1809
+ generation.
1810
+
1811
+ ## Live preview — runs in your browser
1812
+
1813
+ Below an accepted proposal, the builder renders a **live, interactive preview**
1814
+ that runs the generated app's data behaviour **entirely in your browser** — an
1815
+ in-memory reactive store + an interpreter over the proposal graph + the columns
1816
+ parsed from the entity artifacts. No server, no provisioning, no compile. Try it
1817
+ right here — add a row and watch it appear in that table's live list, reactively:
1818
+
1819
+ ```tsx
1820
+ // Fed a sample generated proposal — the { graph, artifacts } apps.generateAppGraph returns.
1821
+ <GeneratedAppPreview graph={proposal.graph} artifacts={proposal.artifacts} />
1822
+ ```
1823
+
1824
+ Each generated table gets a real create form (fields + widgets derived from its
1825
+ columns) and a live list; submitting the form writes to the in-memory store, which
1826
+ pushes to the open list — the same write→push reactivity the real runtime gives.
1827
+
1828
+ ## Guardrails
1829
+
1830
+ - **Never auto-writes.** CLI: dry-run unless `--write`. Cloud: a proposal you
1831
+ review, never an auto-deploy.
1832
+ - **`voltro check` gate.** A proposal is accepted only when `runCheck(graph).ok`;
1833
+ a structurally-invalid one is re-prompted, then rejected — never written.
1834
+ - **Flag-gated (cloud).** `aiBuilder` is off by default; the endpoint fails
1835
+ `FlagDisabled` until an operator turns it on.
1836
+ - **Size caps.** Generation is bounded by file count + total bytes.
1837
+
1838
+ ## What the preview is — and isn't
1839
+
1840
+ The preview **simulates** the app from its spec: it interprets the graph + parsed
1841
+ columns against an in-memory store, giving faithful CRUD + reactivity without any
1842
+ infrastructure. It does **not** execute the literal generated TypeScript against a
1843
+ database — the **deployed** app does that, running the real artifacts on a real
1844
+ store. So the preview is a true feel for how the app behaves, not a bit-for-bit
1845
+ run of the code.