LLM features that don't
re-invent your framework.
The default for "add AI to my app" is somewhere between "wire the Vercel SDK directly and pray" and "adopt a separate agent framework with its own database". Voltro takes a third path: agents, tools, RAG, streaming threads — all first-class primitives in the same runtime as your mutations and your reactive queries. The Vercel AI SDK does the heavy lifting; @voltro/ai gives you a stable surface that works with your tests.
// agents/support.agent.tsx — DESCRIPTOR (browser-safe wire contract)
import { defineAgent } from '@voltro/ai/agent'
import { Schema } from 'effect'
export const support = defineAgent({
name: 'support',
input: Schema.Struct({ prompt: Schema.String }),
})
// agents/support.agent.server.tsx — EXECUTOR (server-only behaviour)
import { defineAgentExecutor } from '@voltro/ai'
import { searchDocs } from '../tools/searchDocs.tool'
export default defineAgentExecutor(support, {
system: 'You are a friendly support agent. Be concise.',
tools: { searchDocs },
maxSteps: 8,
})
// The framework synthesises TWO procedures for free:
// support.send — kicks off a streaming turn
// support.messages — reactive query, streams deltas
// On the client:
const { data: messages } = useSubscription(
'app', ['support.messages', { threadId }], { threadId },
)
const send = useMutation('app', 'support.send')
// Live typewriter bubble is just a row with streaming: true.Six primitives for the AI feature you were going to build.
defineAgent — the chat primitive
One .agent.tsx file declares an LLM chat with typed input, a system prompt, optional tools, and a token budget. The framework auto-synthesises a streaming send action + a reactive query that streams persisted message deltas to the browser.
defineTool — typed function-calling
A *.tool.tsx file declares a Schema-typed input + output. The framework wires it as a model tool. Inputs are validated; the result is decoded against the output schema before the model sees it.
pgvector RAG out of the box
vector(1536) is a first-class column type. The vectorEmbedding() mixin adds the column, the HNSW index, AND a re-embed hook that calls @voltro/ai's embed on every insert/update. nearestNeighbours('text', k) embeds the query inline.
Streaming as a React row
runAssistant patches a persisted message row as deltas arrive. The reactive subscription streams every patch. The live typewriter effect is just a row with streaming: true — no manual WebSocket plumbing, no SSE handler.
Durable agent loops via workflows
For multi-step agent reasoning that must survive a crash (the model call, then a tool call, then waiting for human approval), wrap the loop in a *.workflow.tsx. Each step result is journaled; resumption replays from where it died.
Provider switching + mock for tests
@voltro/ai wraps the Vercel AI SDK behind a stable surface. Swap the provider (Anthropic, OpenAI, local Ollama) without touching call sites. mockAi(...) installs a deterministic provider for tests — no real model calls in CI.
The agent loop IS the runtime.
An agent that needs to (a) stream tokens to a browser, (b) call tools that touch your database, (c) survive a deploy mid-loop, and (d) handle 1000 concurrent users — that's four problems Voltro's existing primitives already solve. Reactive queries do the streaming. Mutations + actions do the tool calls. Workflows do the durability. Multi-tenancy does the isolation.
Choose the right shape:
- • One-shot prompt → text. generateText(prompt). Inside any handler.
- • Schema-constrained output. generateObject(schema, prompt). Inside a mutation or action.
- • Live chat thread. defineAgent + useSubscription on the synthesised messages query.
- • Long agent loop with approvals. Wrap the loop in a workflow; use awaitSignal for the approval.
- • RAG search. vectorEmbedding() mixin on the source table; nearestNeighbours on the query.
A typed answer, a tool, and a flow that pauses for a human.
// A schema out, not a string to parse and hope about.
const triage = await ai.generateObject({
model: 'anthropic:claude-sonnet-4-5',
schema: Schema.Struct({
severity: Schema.Literal('low', 'medium', 'high'),
summary: Schema.String,
owner: Schema.optional(TeamId),
}),
prompt: ticket.body,
})
triage.severity // 'low' | 'medium' | 'high' — typed, validatedA model that returns a schema rather than a string is the difference between a feature and a demo. The pause is the other half: an approval step is a workflow step, so waiting for a person is durable in exactly the way waiting for an API is.
AI primitives, in depth.
What does it mean for AI to be a primitive?
That a prompt, a tool, an agent and an evaluation are declared the way a query or a mutation is — with schemas, an access decision and a name the runtime knows. Not a helper you import into a handler, but a thing the framework can validate, instrument, budget and replay.
The immediate payoff is structured output that is actually structured. You declare the shape you expect and the runtime validates the model's response against it, so a handler receives a typed object rather than a string it has to parse and hope about.
The second payoff is that everything else already knows what to do with it. Cost and token usage are attributed per call, per procedure and per tenant because the call is a known primitive; a run can be recorded and replayed because the runtime saw it happen.
How do tools work without hand-written glue?
A tool is a declaration with an input schema and a handler, and your existing procedures can be exposed as tools by opting in on the declaration they already carry. The schema a procedure validates against is the same schema the model is shown — there is no second description to keep in sync.
Because a tool is a procedure, it inherits the guarantees rather than bypassing them. An agent calling a tool goes through the same access decision, the same tenant scoping and the same validation as a browser calling it — an agent cannot reach data its subject cannot.
That is the part most bolt-on agent frameworks leave to you, and it is the part that matters when an agent is exposed to end users rather than to an internal script.
How do I keep a model change from quietly degrading quality?
By treating it as a regression test. Agent runs are recorded, and `voltro eval` replays them against golden cases with a judge, exiting non-zero when quality drops — so a prompt edit or a model bump is gated by CI rather than judged by whoever happened to try it.
Expectations are the lighter-weight version: declare what should hold about a behaviour and let violations be reported where they happen. Experiments cover the other direction — a deterministic bucketing so two prompts or two models can be compared on real traffic.
None of this needs a separate evaluation service. The runs are already recorded because the calls are primitives, so the evaluation reads what happened rather than re-running an approximation of it.
What stops AI costs from surprising me?
A declared cost budget, which is a primitive rather than a dashboard alert. Because the runtime sees each call and its token usage, exceeding a budget is something it can act on — refuse, degrade, or route elsewhere — instead of something somebody notices in a bill.
Attribution is per tenant as well as per procedure, which is what makes usage-based pricing or per-customer limits expressible at all. A shared counter across all tenants cannot answer the question a SaaS actually has.
Caching applies too: a semantic cache can serve a near-duplicate request without a model call. It is per-process today for the index that finds the near-duplicate — a cross-process semantic lookup would need a durable index, which is documented rather than implied.
What is declared rather than glued.
| Primitive | What it gives you |
|---|---|
| definePrompt | A named, versionable prompt with typed inputs rather than a template string in a handler. |
| defineTool | A schema-validated tool; an existing procedure can be exposed as one by opting in. |
| defineAgent | A tool-using loop that inherits access, tenancy and validation from the procedures it calls. |
| Structured output | A declared output shape validated at runtime, so a handler gets a typed object rather than a string. |
| defineEval | Replay recorded runs against golden cases with a judge; a deploy gate rather than a vibe check. |
| defineCostBudget | Token and cost limits the runtime can enforce, attributed per procedure and per tenant. |
Frequently asked questions
Which model providers are supported?
The layer is provider-agnostic: a provider is an implementation of a spec, so swapping one is a configuration change rather than a rewrite of your prompts and tools. A mock provider ships for tests so a suite never depends on a network call or a budget.
Can an agent reach data the user cannot?
No. A tool is a procedure, so an agent's call goes through the same access decision, tenant scoping and validation as any other caller. The agent acts as a subject rather than beside the authorization system.
Is the structured output actually validated?
Yes, against the declared schema at runtime. That is the difference between typed output and a type annotation over a parsed string — a model response that does not match is an error at the boundary rather than a surprise three layers later.
How do I test AI behaviour in CI?
Record runs and replay them with `voltro eval` against golden cases and a judge, which exits non-zero on regression. For unit tests, the mock provider gives deterministic responses so a suite is fast and free.
Can long-running AI work survive a crash?
Yes — that is what the flows plugin is for: durable multi-step pipelines, deterministic or agentic, with human-in-the-loop steps, journalled like any other workflow so a run resumes rather than restarts.
AI is just one more handler shape.
Durable workflows
Multi-step agent loops belong in a workflow — each LLM call, each tool call, each approval is a durable step.
Reactive queries
Agent message streams ride the SAME reactive subscription as a todo list. No special chat-streaming infrastructure.
Schema DSL
vector(1536) + vectorEmbedding() mixin add a typed vector column, HNSW index, and auto-embed hook in one declaration.
Open the framework. See it for yourself.
Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.