Drop a file.
The runtime wires it in.
Every capability is a typed descriptor discovered by file convention. Name a file *.query.ts, *.mutation.ts, *.workflow.tsx — the CLI’s discovery walker finds it on next boot and wires it into the typed RPC group. No import barrels, no registration, no codegen command to remember.
// Drop a file with the right suffix — that's it.
todos.list.query.ts → useSubscription
todos.create.mutation.ts → useMutation
sendEmail.action.ts → useAction
order.fulfil.workflow.tsx → useWorkflow
nightly.cron.tsx → scheduled
stripe.webhook.tsx → signed inbound
// Each: a browser-safe descriptor + a .server executor.
// The discovery walker wires it in on next boot.The primitive set, by file suffix.
Descriptor + server executor
Every procedure is two files: a browser-safe descriptor (name, effect/Schema, metadata) and a server-only executor (*.server.ts — DB, SDKs, secrets). The browser-safe rule is transitive, so your schemas ship to the client without dragging node internals with them.
The core quartet
query (live reactive read → useSubscription), mutation (atomic transactional write → useMutation), action (one-shot external I/O → useAction), stream (server→client push → useAgentStream). Input, output and errors are all effect/Schema.
Durable & scheduled
workflow (durable multi-step), cron (scheduled), trigger (domain event → fan-out), subscribe (per-table post-commit reaction), aggregate (materialised query). The durable ones survive deploys, retries and crashes.
AI & tools
agent (persisted AI chat → useAgent), tool (model-callable function), reaction (a change fires an agent behind mandatory spend guards). Agents auto-migrate their thread + message tables, tenant-scoped.
HTTP, webhooks & email
route (public raw-HTTP endpoint), webhook (signed inbound + durable outbound), email (React-Email template), plus seed and startup hooks — the same discovery model, different suffix.
Unique RPC tags, checked at boot
Every descriptor has a globally-unique dotted name (todos.list → todosListRpc). Collisions fail boot; there is no hand-written per-endpoint SDK, and voltro dev regenerates the typed client on every change.
Convention over registration.
The discovery walker does the wiring
You never maintain a registry or an import barrel. The CLI walks the tree, finds every file with a known suffix, and wires it into the generated RPC group — types flowing to the client through Vite’s module graph. The one rule to respect: a descriptor and everything it imports must stay browser-safe, so server-only code lives in the *.server file next to it.
The whole family, one file each.
// queries/orders.list.query.ts
export const listOrders = defineQuery({
name: 'orders.list',
source: 'orders', // live: any write to orders re-pushes this
input: Schema.Struct({ status: Schema.optional(Schema.String) }),
output: Schema.Array(Order),
})Each is a file whose NAME is its registration. There is no registry to append to and no decorator to remember, so a procedure that exists is a procedure the runtime knows about — and one you deleted is gone from the client surface on the next boot.
The primitive family, in depth.
Why does the framework have named primitives at all?
Because the alternative is that every team invents its own vocabulary for the same six or seven things, and none of it is inspectable. A query, a mutation, an action, a stream, a workflow, a schedule, an event: naming them means the runtime knows what each one is, and can do things on your behalf that a plain function cannot.
A query declares the tables it reads, so the runtime can invalidate its cache and push deltas when they change. A mutation declares what it writes, so optimistic updates are derived rather than hand-written. A workflow declares its steps, so a crash resumes rather than restarts. The declaration is what buys the behaviour.
It also means tooling can answer questions about your app without running it: which procedures exist, what each one accepts and returns, who may call it, what it touches. That is how the CLI can typecheck your surface, generate an OpenAPI document, or tell you a guard requires a scope no role grants.
How do I choose between a query, an action and a mutation?
A query READS and is subscribable — declare it when a client should see the answer change over time. A mutation WRITES to declared tables and gets optimistic patching and change broadcast for free. An action is the escape hatch: it does something that is neither a plain read nor a table write — call a third-party API, mint a token, kick off a job.
The wrong choice is usually an action where a mutation belonged, because the action skips the machinery: no declared targets means no optimistic update and no automatic invalidation, so the screen goes stale and somebody adds a manual refetch to compensate.
A stream is for a response that arrives in pieces — a token-by-token AI completion, a long export. A workflow is for work that must survive a crash. If you are unsure between a mutation and a workflow, ask whether a failure halfway through leaves the system inconsistent; if yes, it is a workflow.
What does a primitive declaration actually contain?
A name, an input schema, an output schema, an access decision, and a handler. The schemas are real runtime validators, not type annotations that vanish at build: an input the schema does not describe is rejected, and an output that does not match is a defect caught at the boundary rather than in a client three layers away.
The access decision is mandatory. Every wire-reachable primitive declares either a guard or an explicit, written reason for being open, and the boot refuses to start if one is undecided — because the failure mode of a forgotten guard is silent and total.
Everything else is optional metadata that unlocks behaviour: the tables a query reads, the targets a mutation writes, retry policy, rate limits, whether it appears in the generated REST surface or is exposed as a tool to an AI agent. You opt into what you need and the rest costs nothing.
Are primitives just files, or is there a registry?
Files. A `*.query.ts` is a query, a `*.mutation.ts` is a mutation, a `*.workflow.tsx` is a workflow — discovered by convention, with no registry to append to and nothing to remember at boot. Moving a file moves the primitive; deleting one deletes it.
The convention carries a real constraint on the client side: everything the codegen pulls into the browser bundle must be browser-safe, so a descriptor and its transitive imports cannot reach the database handle or a Node built-in. That is why workflows are split into a descriptor and a server-side executor.
The framework enforces that structurally rather than by review — a boot-time guard walks the generated graph and aborts with the exact import chain when something server-only would end up in the browser. A leak used to surface as a bundle exploding to tens of megabytes, or a crash about a Node module being externalised.
Which primitive to reach for.
| Primitive | Use it when |
|---|---|
| Query | A client reads data that should stay live — declares its tables, gets subscriptions and cache invalidation. |
| Mutation | You write to declared tables — gets derived optimistic updates and change broadcast. |
| Action | The work is neither a read nor a table write: a third-party call, a token, a side effect. |
| Stream | The response arrives in pieces — AI completions, long exports, progressive results. |
| Workflow | The work must survive a crash mid-way, or waits on a human. |
| Schedule / event | Work runs on a clock, or fans out from something that happened elsewhere. |
Frequently asked questions
Can I just write a plain function instead?
You can, and for internal helpers you should. A primitive earns its declaration when something crosses the wire or must be coordinated: that is when the runtime needs to know what it is to validate it, guard it, subscribe to it, or resume it.
Is the schema validation actually enforced at runtime?
Yes, in both directions. An input the schema does not describe is rejected rather than trimmed — since 0.37 an undeclared field fails the call instead of being silently discarded — and an output that does not match is caught at the boundary.
What happens if I forget to declare access?
The application refuses to boot and names the primitive. That is deliberate: an undecided guard fails open, and a failure that only shows up as unauthorised access is one nobody notices until it matters.
How do primitives reach a REST client or an AI agent?
By opting in on the declaration. A primitive can be projected into a generated OpenAPI surface, or exposed as a callable tool to an agent, using the schemas it already carries — there is no second definition to keep in sync.
Do I have to use Effect to write a handler?
Handlers compose with Effect and that is how services are reached, but the common case reads like ordinary async code. You do not need to learn the whole ecosystem to write a query; you need it when you want retries, layers or structured concurrency, which is when it earns itself.
Primitives are the surface of the runtime.
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.