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 execute

v.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.