@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20236 -0
- package/bin/voltro.mjs +43 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +9 -0
- package/dist/commands-CfPH2Wf4.js +18061 -0
- package/dist/frameworkInspectState-CX2250XB.js +86 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +5 -0
- package/dist/inspectState.d.ts +116 -0
- package/dist/inspectState.js +2 -0
- package/dist/startup.d.ts +26 -0
- package/dist/startup.js +2 -0
- package/dist/startupRunner-CRhuUl91.js +71 -0
- package/package.json +88 -0
- package/templates/AGENTS.core.md +258 -0
- package/templates/AGENTS.md +351 -0
- package/templates/agent-docs/_index.md +93 -0
- package/templates/agent-docs/_manifest.json +655 -0
- package/templates/agent-docs/ai.md +1845 -0
- package/templates/agent-docs/authentication.md +1788 -0
- package/templates/agent-docs/caching.md +624 -0
- package/templates/agent-docs/cli.md +1650 -0
- package/templates/agent-docs/configuration.md +295 -0
- package/templates/agent-docs/data.md +2172 -0
- package/templates/agent-docs/database/advancedqueries.md +1583 -0
- package/templates/agent-docs/database/columntypes.md +1200 -0
- package/templates/agent-docs/database/hosting.md +881 -0
- package/templates/agent-docs/database/migrations.md +2938 -0
- package/templates/agent-docs/database/misc.md +270 -0
- package/templates/agent-docs/database/overview.md +108 -0
- package/templates/agent-docs/database/querying.md +1622 -0
- package/templates/agent-docs/database/scaling.md +331 -0
- package/templates/agent-docs/database/schema.md +1458 -0
- package/templates/agent-docs/database/seedsdialects.md +1235 -0
- package/templates/agent-docs/database/transactions.md +285 -0
- package/templates/agent-docs/deployment.md +999 -0
- package/templates/agent-docs/internationalization.md +359 -0
- package/templates/agent-docs/introduction.md +438 -0
- package/templates/agent-docs/multi-tenancy.md +610 -0
- package/templates/agent-docs/observability.md +350 -0
- package/templates/agent-docs/plugins.md +1273 -0
- package/templates/agent-docs/reference.md +978 -0
- package/templates/agent-docs/routing.md +1553 -0
- package/templates/agent-docs/scheduling.md +666 -0
- package/templates/agent-docs/schema-driven-ui.md +607 -0
- package/templates/agent-docs/security.md +42 -0
- package/templates/agent-docs/templates/apibackends.md +3214 -0
- package/templates/agent-docs/templates/appshells.md +2062 -0
- package/templates/agent-docs/templates/custom.md +128 -0
- package/templates/agent-docs/templates/overview.md +122 -0
- package/templates/agent-docs/templates/serverless.md +315 -0
- package/templates/agent-docs/testing.md +376 -0
- package/templates/agent-docs/workflows.md +1351 -0
- package/templates/apps/api-ai/README.md +105 -0
- package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
- package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
- package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
- package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
- package/templates/apps/api-ai/app.config.ts +57 -0
- package/templates/apps/api-ai/database/schema.ts +67 -0
- package/templates/apps/api-ai/package.json +27 -0
- package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
- package/templates/apps/api-ai/template.json +6 -0
- package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
- package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
- package/templates/apps/api-ai/tsconfig.json +5 -0
- package/templates/apps/api-auth/.env +17 -0
- package/templates/apps/api-auth/README.md +109 -0
- package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
- package/templates/apps/api-auth/actions/me.action.ts +19 -0
- package/templates/apps/api-auth/app.config.ts +59 -0
- package/templates/apps/api-auth/database/schema.ts +26 -0
- package/templates/apps/api-auth/package.json +28 -0
- package/templates/apps/api-auth/template.json +6 -0
- package/templates/apps/api-auth/tests/me.test.ts +37 -0
- package/templates/apps/api-auth/tsconfig.json +5 -0
- package/templates/apps/api-backend/README.md +25 -0
- package/templates/apps/api-backend/app.config.ts +39 -0
- package/templates/apps/api-backend/database/schema.ts +57 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend/package.json +28 -0
- package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend/template.json +6 -0
- package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend/tsconfig.json +5 -0
- package/templates/apps/api-backend-deactivation/README.md +49 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
- package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
- package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
- package/templates/apps/api-backend-deactivation/package.json +28 -0
- package/templates/apps/api-backend-deactivation/template.json +6 -0
- package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
- package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
- package/templates/apps/api-backend-mail/README.md +39 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
- package/templates/apps/api-backend-mail/app.config.ts +34 -0
- package/templates/apps/api-backend-mail/database/schema.ts +57 -0
- package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mail/package.json +30 -0
- package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mail/template.json +6 -0
- package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mail/tsconfig.json +5 -0
- package/templates/apps/api-backend-mariadb/.env.example +35 -0
- package/templates/apps/api-backend-mariadb/README.md +31 -0
- package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
- package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mariadb/package.json +30 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mariadb/template.json +6 -0
- package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/README.md +86 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
- package/templates/apps/api-backend-storage/app.config.ts +38 -0
- package/templates/apps/api-backend-storage/database/schema.ts +57 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-storage/package.json +27 -0
- package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-storage/template.json +6 -0
- package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-storage/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/.env +17 -0
- package/templates/apps/api-data-advanced/README.md +88 -0
- package/templates/apps/api-data-advanced/app.config.ts +56 -0
- package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
- package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
- package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
- package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
- package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
- package/templates/apps/api-data-advanced/database/index.ts +23 -0
- package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
- package/templates/apps/api-data-advanced/package.json +28 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
- package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
- package/templates/apps/api-data-advanced/template.json +6 -0
- package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
- package/templates/apps/api-data-advanced/tsconfig.json +5 -0
- package/templates/apps/api-durable/README.md +81 -0
- package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
- package/templates/apps/api-durable/app.config.ts +41 -0
- package/templates/apps/api-durable/database/schema.ts +65 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
- package/templates/apps/api-durable/package.json +28 -0
- package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
- package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
- package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
- package/templates/apps/api-durable/template.json +6 -0
- package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
- package/templates/apps/api-durable/tsconfig.json +5 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
- package/templates/apps/api-feature-flags/README.md +63 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
- package/templates/apps/api-feature-flags/app.config.ts +41 -0
- package/templates/apps/api-feature-flags/database/schema.ts +37 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
- package/templates/apps/api-feature-flags/package.json +29 -0
- package/templates/apps/api-feature-flags/template.json +6 -0
- package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
- package/templates/apps/api-feature-flags/tsconfig.json +5 -0
- package/templates/apps/api-governance/.env +4 -0
- package/templates/apps/api-governance/README.md +61 -0
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
- package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
- package/templates/apps/api-governance/app.config.ts +35 -0
- package/templates/apps/api-governance/database/schema.ts +41 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
- package/templates/apps/api-governance/package.json +29 -0
- package/templates/apps/api-governance/template.json +6 -0
- package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
- package/templates/apps/api-governance/tsconfig.json +5 -0
- package/templates/apps/api-kv/README.md +100 -0
- package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
- package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
- package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
- package/templates/apps/api-kv/app.config.ts +48 -0
- package/templates/apps/api-kv/database/schema.ts +62 -0
- package/templates/apps/api-kv/package.json +28 -0
- package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
- package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
- package/templates/apps/api-kv/template.json +6 -0
- package/templates/apps/api-kv/tests/sync.test.ts +103 -0
- package/templates/apps/api-kv/tsconfig.json +5 -0
- package/templates/apps/api-moderation/README.md +46 -0
- package/templates/apps/api-moderation/app.config.ts +33 -0
- package/templates/apps/api-moderation/database/schema.ts +45 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
- package/templates/apps/api-moderation/package.json +29 -0
- package/templates/apps/api-moderation/template.json +6 -0
- package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
- package/templates/apps/api-moderation/tsconfig.json +5 -0
- package/templates/apps/api-observability/README.md +106 -0
- package/templates/apps/api-observability/app.config.ts +32 -0
- package/templates/apps/api-observability/database/schema.ts +38 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
- package/templates/apps/api-observability/package.json +29 -0
- package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
- package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
- package/templates/apps/api-observability/template.json +6 -0
- package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
- package/templates/apps/api-observability/tsconfig.json +5 -0
- package/templates/apps/api-ratelimit/README.md +49 -0
- package/templates/apps/api-ratelimit/app.config.ts +42 -0
- package/templates/apps/api-ratelimit/database/schema.ts +37 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
- package/templates/apps/api-ratelimit/package.json +29 -0
- package/templates/apps/api-ratelimit/template.json +6 -0
- package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
- package/templates/apps/api-ratelimit/tsconfig.json +5 -0
- package/templates/apps/api-rbac/README.md +59 -0
- package/templates/apps/api-rbac/app.config.ts +48 -0
- package/templates/apps/api-rbac/database/schema.ts +37 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
- package/templates/apps/api-rbac/package.json +29 -0
- package/templates/apps/api-rbac/template.json +6 -0
- package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
- package/templates/apps/api-rbac/tsconfig.json +5 -0
- package/templates/apps/api-rest/README.md +85 -0
- package/templates/apps/api-rest/app.config.ts +70 -0
- package/templates/apps/api-rest/database/schema.ts +52 -0
- package/templates/apps/api-rest/lib/product.ts +30 -0
- package/templates/apps/api-rest/package.json +27 -0
- package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
- package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
- package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
- package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
- package/templates/apps/api-rest/template.json +6 -0
- package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
- package/templates/apps/api-rest/tsconfig.json +5 -0
- package/templates/apps/api-saas/README.md +106 -0
- package/templates/apps/api-saas/app.config.ts +51 -0
- package/templates/apps/api-saas/database/schema.ts +50 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas/package.json +31 -0
- package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
- package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas/template.json +6 -0
- package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
- package/templates/apps/api-saas/tsconfig.json +5 -0
- package/templates/apps/api-search/README.md +68 -0
- package/templates/apps/api-search/app.config.ts +31 -0
- package/templates/apps/api-search/database/schema.ts +47 -0
- package/templates/apps/api-search/lib/search.ts +27 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
- package/templates/apps/api-search/package.json +29 -0
- package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
- package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
- package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
- package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
- package/templates/apps/api-search/template.json +6 -0
- package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
- package/templates/apps/api-search/tsconfig.json +5 -0
- package/templates/apps/api-versioning/README.md +51 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
- package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
- package/templates/apps/api-versioning/app.config.ts +24 -0
- package/templates/apps/api-versioning/database/schema.ts +38 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
- package/templates/apps/api-versioning/package.json +29 -0
- package/templates/apps/api-versioning/template.json +6 -0
- package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
- package/templates/apps/api-versioning/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/.env +6 -0
- package/templates/apps/api-webhooks/README.md +106 -0
- package/templates/apps/api-webhooks/app.config.ts +16 -0
- package/templates/apps/api-webhooks/database/schema.ts +49 -0
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
- package/templates/apps/api-webhooks/package.json +28 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
- package/templates/apps/api-webhooks/template.json +6 -0
- package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
- package/templates/apps/api-webhooks/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
- package/templates/apps/changelog/README.md +77 -0
- package/templates/apps/changelog/app.config.ts +27 -0
- package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
- package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
- package/templates/apps/changelog/package.json +30 -0
- package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
- package/templates/apps/changelog/src/globals.css +66 -0
- package/templates/apps/changelog/src/globals.d.ts +16 -0
- package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
- package/templates/apps/changelog/src/lib/locale.ts +55 -0
- package/templates/apps/changelog/src/lib/releases.ts +21 -0
- package/templates/apps/changelog/src/locales/de.ts +22 -0
- package/templates/apps/changelog/src/locales/en.ts +30 -0
- package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
- package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
- package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
- package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
- package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
- package/templates/apps/changelog/src/pages/index.tsx +55 -0
- package/templates/apps/changelog/src/pages/layout.tsx +84 -0
- package/templates/apps/changelog/template.json +6 -0
- package/templates/apps/changelog/tsconfig.json +11 -0
- package/templates/apps/edge-functions/README.md +55 -0
- package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
- package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
- package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
- package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
- package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
- package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
- package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
- package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
- package/templates/apps/edge-functions/package.json +21 -0
- package/templates/apps/edge-functions/template.json +6 -0
- package/templates/apps/edge-functions/tsconfig.json +10 -0
- package/templates/apps/frontend-admin/README.md +40 -0
- package/templates/apps/frontend-admin/app.config.ts +37 -0
- package/templates/apps/frontend-admin/package.json +30 -0
- package/templates/apps/frontend-admin/src/config.ts +8 -0
- package/templates/apps/frontend-admin/src/globals.css +76 -0
- package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
- package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
- package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
- package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
- package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
- package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
- package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
- package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
- package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
- package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-admin/template.json +6 -0
- package/templates/apps/frontend-admin/tsconfig.json +11 -0
- package/templates/apps/frontend-app/README.md +98 -0
- package/templates/apps/frontend-app/app.config.ts +43 -0
- package/templates/apps/frontend-app/package.json +30 -0
- package/templates/apps/frontend-app/src/locales/de.ts +36 -0
- package/templates/apps/frontend-app/src/locales/en.ts +43 -0
- package/templates/apps/frontend-app/src/locales/index.ts +15 -0
- package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
- package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
- package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
- package/templates/apps/frontend-app/template.json +6 -0
- package/templates/apps/frontend-app/tsconfig.json +11 -0
- package/templates/apps/frontend-blank/README.md +18 -0
- package/templates/apps/frontend-blank/app.config.ts +29 -0
- package/templates/apps/frontend-blank/package.json +29 -0
- package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
- package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
- package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
- package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
- package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
- package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
- package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
- package/templates/apps/frontend-blank/template.json +6 -0
- package/templates/apps/frontend-blank/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/README.md +65 -0
- package/templates/apps/frontend-contact/app.config.ts +25 -0
- package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
- package/templates/apps/frontend-contact/package.json +33 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
- package/templates/apps/frontend-contact/src/config.ts +12 -0
- package/templates/apps/frontend-contact/src/globals.css +84 -0
- package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
- package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
- package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
- package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
- package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
- package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
- package/templates/apps/frontend-contact/template.json +6 -0
- package/templates/apps/frontend-contact/tsconfig.json +11 -0
- package/templates/apps/frontend-dashboard/README.md +54 -0
- package/templates/apps/frontend-dashboard/app.config.ts +39 -0
- package/templates/apps/frontend-dashboard/package.json +29 -0
- package/templates/apps/frontend-dashboard/src/config.ts +8 -0
- package/templates/apps/frontend-dashboard/src/globals.css +72 -0
- package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
- package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
- package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
- package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
- package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-dashboard/template.json +6 -0
- package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
- package/templates/apps/frontend-docs/README.md +19 -0
- package/templates/apps/frontend-docs/app.config.ts +25 -0
- package/templates/apps/frontend-docs/package.json +29 -0
- package/templates/apps/frontend-docs/src/globals.css +32 -0
- package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
- package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
- package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
- package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
- package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-docs/template.json +6 -0
- package/templates/apps/frontend-docs/tsconfig.json +11 -0
- package/templates/apps/frontend-i18n/README.md +61 -0
- package/templates/apps/frontend-i18n/app.config.ts +33 -0
- package/templates/apps/frontend-i18n/package.json +28 -0
- package/templates/apps/frontend-i18n/src/globals.css +46 -0
- package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
- package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
- package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
- package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
- package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
- package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
- package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
- package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
- package/templates/apps/frontend-i18n/template.json +6 -0
- package/templates/apps/frontend-i18n/tsconfig.json +11 -0
- package/templates/apps/frontend-landing/README.md +17 -0
- package/templates/apps/frontend-landing/app.config.ts +25 -0
- package/templates/apps/frontend-landing/package.json +29 -0
- package/templates/apps/frontend-landing/src/globals.css +23 -0
- package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
- package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
- package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
- package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
- package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-landing/template.json +6 -0
- package/templates/apps/frontend-landing/tsconfig.json +11 -0
- package/templates/apps/frontend-spa/README.md +45 -0
- package/templates/apps/frontend-spa/app.config.ts +27 -0
- package/templates/apps/frontend-spa/package.json +29 -0
- package/templates/apps/frontend-spa/src/globals.css +84 -0
- package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
- package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
- package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
- package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
- package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
- package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-spa/template.json +6 -0
- package/templates/apps/frontend-spa/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr/README.md +68 -0
- package/templates/apps/frontend-ssr/app.config.ts +32 -0
- package/templates/apps/frontend-ssr/package.json +29 -0
- package/templates/apps/frontend-ssr/src/globals.css +67 -0
- package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
- package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
- package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
- package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
- package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
- package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
- package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
- package/templates/apps/frontend-ssr/template.json +6 -0
- package/templates/apps/frontend-ssr/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr-api/README.md +50 -0
- package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
- package/templates/apps/frontend-ssr-api/package.json +30 -0
- package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
- package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
- package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
- package/templates/apps/frontend-ssr-api/template.json +6 -0
- package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
- package/templates/apps/frontend-static-blog/README.md +49 -0
- package/templates/apps/frontend-static-blog/app.config.ts +34 -0
- package/templates/apps/frontend-static-blog/package.json +28 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
- package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
- package/templates/apps/frontend-static-blog/src/globals.css +75 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
- package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
- package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
- package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
- package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
- package/templates/apps/frontend-static-blog/template.json +6 -0
- package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
- package/templates/baselines/bare/.env.example +37 -0
- package/templates/baselines/bare/README.md +47 -0
- package/templates/baselines/bare/baseline.json +31 -0
- package/templates/baselines/bare/deploy/README.md +43 -0
- package/templates/baselines/bare/deploy/voltro.service.example +36 -0
- package/templates/baselines/compose/.env.example +46 -0
- package/templates/baselines/compose/README.md +69 -0
- package/templates/baselines/compose/baseline.json +51 -0
- package/templates/baselines/compose/docker/.dockerignore +38 -0
- package/templates/baselines/compose/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose/docker-compose.dev.yml +89 -0
- package/templates/baselines/compose/docker-compose.prod.yml +87 -0
- package/templates/baselines/compose/docker-compose.yml +41 -0
- package/templates/baselines/compose-mariadb/.env.example +57 -0
- package/templates/baselines/compose-mariadb/README.md +78 -0
- package/templates/baselines/compose-mariadb/baseline.json +51 -0
- package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
- package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
- package/templates/baselines/helm/.env.example +39 -0
- package/templates/baselines/helm/README.md +98 -0
- package/templates/baselines/helm/baseline.json +53 -0
- package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
- package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
- package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
- package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
- package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
- package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
- package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
- package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
- package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
- package/templates/baselines/helm/deploy/README.md +94 -0
- 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.
|