Data copilot
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.
The data copilot turns a natural-language question into a validated,
read-only query and returns the rows — without ever trusting the model with
your schema. The model proposes a query shape; the framework validates every
table and column against a manifest and refuses anything it doesn't recognise
(a hallucinated column is a typed rejection, never a blind WHERE). The
validated descriptor is a plain SELECT that runs as the calling subject, so
tenant and row scoping apply exactly as they do for any other query.
Read-only in v1. The copilot only ever reads. Trust hinges on validation — one hallucinated number burns it — so schema-validation is non-negotiable, not a nicety.
Server: the copilot.ask action
@voltro/ai's runDataCopilot(question, schema, { propose }) builds the grammar
prompt, asks the model for a constrained proposal, and validates it against your
CopilotSchema. It returns either a read-only descriptor or a typed
CopilotRejected. You run the descriptor as the subject and shape the answer:
// copilot.ask.action.ts — the browser-safe descriptor
import { defineAction } from '@voltro/protocol'
import { Schema } from 'effect'
export const ask = defineAction({
name: 'copilot.ask',
input: Schema.Struct({ question: Schema.String }),
output: Schema.Union(
Schema.Struct({ ok: Schema.Literal(true), rows: Schema.Array(Schema.Record({ key: Schema.String, value: Schema.Unknown })) }),
Schema.Struct({ ok: Schema.Literal(false), reason: Schema.String }),
),
})// copilot.ask.action.server.ts — the server executor (imports @voltro/ai)
import { Effect } from 'effect'
import { EffectStore } from '@voltro/runtime'
import { runDataCopilot, generateObject, CopilotProposalSchema, type CopilotSchema } from '@voltro/ai'
// The manifest the model is constrained to — only these tables/columns exist.
const schema: CopilotSchema = {
tables: [{ name: 'todos', columns: [
{ name: 'id', type: 'string' }, { name: 'title', type: 'string' },
{ name: 'done', type: 'boolean' }, { name: 'dueAt', type: 'date' },
] }],
}
const execute = (input: { question: string }, ctx: AppContext) =>
Effect.gen(function* () {
const store = yield* EffectStore
const v = yield* Effect.promise(() =>
runDataCopilot(input.question, schema, {
propose: ({ system, prompt }) =>
Effect.runPromise(
generateObject({ system, prompt, schema: CopilotProposalSchema }).pipe(Effect.map((r) => r.object)),
),
}),
)
if (!v.ok) return { ok: false as const, reason: v.rejection.reason }
// The validated SELECT runs AS the subject → tenant + row scope apply.
const rows = yield* store.query(v.descriptor)
return { ok: true as const, rows }
})
export default executev.descriptor is already a QueryDescriptor, so nothing needs casting. In an Effect-form executor read through EffectStore (yield* EffectStore) rather than lifting ctx.store.query with Effect.promise — the lift discards the typed StoreError channel that store.query gives you.
Client: useDataCopilot + <DataCopilot>
The hook is a thin binding over useAction — it imports nothing from
@voltro/ai (server-only), so the copilot engine never reaches the browser
bundle:
import { useDataCopilot } from '@voltro/client'
const copilot = useDataCopilot('app', 'copilot.ask')
await copilot.ask('how many open todos are due this week?')
// copilot.answer: { ok: true, rows } | { ok: false, reason }
// copilot.pending, copilot.error, copilot.reset()Or drop in the headless <DataCopilot> component (a prompt box + the
refusal-or-rows answer), styled with data-voltro-* hooks:
import { DataCopilot } from '@voltro/ui'
<DataCopilot api="app" action="copilot.ask" placeholder="Ask about your data…" />What makes it safe
- Schema-constrained. Every proposed table, projection column, filter column,
order column, operator, and aggregate is checked against the manifest. Unknown
→ a typed
CopilotRejected(unknown-table/unknown-column/ …), never an executed query. - Read-only. The descriptor is always a
SELECT; the copilot can't write. - Runs as the subject. Tenant scope + row visibility apply because the descriptor runs through the same store path as any handler — a caller in tenant A never sees tenant B's rows.
- Bounded. The row count is capped (
COPILOT_MAX_LIMIT) so a question can't pull the whole table.