Your schema already typed the backend.
Now it builds the UI.
Every Voltro procedure carries an effect/Schema — input, output, and the table it writes. Schema-driven UI projects that into the frontend: a form is a binding to a mutation, a table is a binding to a query. You get fields, columns, validation, and live auto-optimistic updates without writing the glue. And because the binding is a reactive, auto-optimistic backend — not a dead REST schema — the generated table is a live subscription, not a grid you refetch.
// The form IS the mutation. Fields come from its input Schema;
// validation, submit, and op-correct optimistic are wired for you.
import { AutoForm, DataTable } from '@voltro/web'
// create-vs-edit is just two mutations — no "CRUD mode" switch
<AutoForm api="app" mutation="todos.create" />
<AutoForm api="app" mutation="todos.update" defaults={row} />
// The table IS the query. Columns from its output Schema; rows are a
// LIVE subscription — they update on any write, with no refetch.
<DataTable
api="app"
query="todos.list"
rowActions={(r) => <button onClick={() => del.mutate({ id: r.id })}>Delete</button>}
/>The descriptor is the single source of truth.
You already declared the input Schema, the output Schema, and the target table once, on the server. Schema-driven UI reads those — it never asks you to re-describe your data in a form config. Annotation customizes how a field renders; it never re-plumbs what the field is bound to.
Forms bind to a mutation
AutoForm reads the mutation's input Schema and renders the fields: a Literal union → a select, a string → text, a boolean → a checkbox. Validation, submit, and op-correct auto-optimistic come for free. Client and server share the exact same Schema.
Tables bind to a query
DataTable reads the query's output Schema for columns and subscribes for rows. The list updates on every write — no refetch, no cache to invalidate. pageSize opts into a live grow-the-window "Load more" that keeps streaming.
Pickers bind to a query
A reference() column defaults to a debounced, live typeahead off the conventional search query. AsyncSelect (or the headless useQueryField) gives you create-on-the-fly comboboxes with zero config — the option list is itself reactive.
A customization ladder, not a cliff
Annotate a field → render-prop one widget → swap a widget kind app-wide → own the whole layout → eject to the headless hook. On NO rung do you lose the binding. This is where every other schema→UI generator dies; here it's the design.
Reactive components, not just CRUD
Drop-in UI for the durable + reactive backend: WorkflowProgress for a live step timeline, PresenceAvatars / EditingIndicator for multiplayer, AgentChat for a reconnect-surviving AI chat. Each has a headless hook underneath.
A toolbox of bound hooks
useCan (gate on the same scopes the server enforces), usePreview (dry-run a mutation in a rolled-back transaction), useUndo (universal Ctrl-Z over mutations), useAsyncValidation, RecordView, a typed analytics catalog, an offline outbox, windowed subscriptions. All projections of the same descriptor graph.
Sugar on top, headless underneath.
// Every component is sugar over a headless binding. Eject from the
// RENDERING without losing the binding (schema + submit + optimistic).
import { useFormBinding, useDataTable } from '@voltro/client'
const form = useFormBinding('app', 'todos.create')
// → { fields, values, errors, isValid, pending, setValue, submit, reset }
const table = useDataTable('app', 'todos.list', { pageSize: 25 })
// → { columns, rows, loading, sort, toggleSort, loadMore, hasMore }
// rows stay reactive as the window grows. Render any way you like.Why the generated version actually beats the hand-written one.
Auto-generated CRUD usually fails because it generates against a dead REST/SQL schema and hits a wall the moment you need anything custom. Voltro's version is different on one axis: the generated UI binds to a reactive, auto-optimistic runtime. The generation is the delivery; the reactivity is the value.
One change, two surfaces:
Add a column to the table → the entity Schema changes → the mutation's input Schema changes → AutoForm grows the field AND DataTable grows the column, both typed, both validated, on the next reload. No form library to update, no column config to sync, no optimistic reducer to hand-write. Eject to the headless hook the day you outgrow the defaults — the binding (schema + submit + live updates) survives the eject.
It composes with the rest of the runtime.
Reactive queries
The live subscription behind every DataTable. One hook, push-based, auto-optimistic — the data layer the UI binds to.
End-to-end types
The effect/Schema that types the form is the same one that types the wire and the handler. Change it once; the compiler finds every gap.
AI primitives
AgentChat + AppAgent bind to a synthesized agent; exposeAsTool turns any procedure into a safe LLM tool bounded by the caller's permissions.
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.