AI over your rows
Semantic verbs for the questions SQL cannot answer, and how to make them cheap.
SQL can tell you how many tickets you have. It cannot tell you what they are about.
sdk.ai covers that gap with a small set of verbs that read row content — classify, extract,
summarize, search by meaning — and it does so inside the same policy boundary as every other
read. Reads go through the governed read path, so your policy, tenancy scope, and column
redaction all apply before a single byte reaches a model provider. There is no privileged path
here and there is not meant to be one.
The verbs
| Verb | Answers |
|---|---|
sdk.ai.digest | "What are these rows about?" — prose |
sdk.ai.classify | "Group these by theme" — compact counts per label |
sdk.ai.extract | "Pull the vendor and amount out of this text" — structured fields |
sdk.ai.filter | "Which of these are about shipping?" — matching row uids |
sdk.ai.annotate | Same as classify, but stores the label in a column |
sdk.ai.redact | Mask personal details inside stored text |
sdk.ai.embed / sdk.ai.similar | Search by meaning; find related or duplicate rows |
Full signatures are in the Handler SDK reference.
Sentiment analysis is not a separate verb — it is classify with three labels. Neither is
"compare" — it is similar plus a digest.
Every read is bounded, and says so
Each verb reads a sample (100 rows by default, 500 maximum) rather than the whole table, so cost is predictable before you run anything. The result tells you honestly what it saw:
const { text, analyzed, matched, sampled } = await sdk.ai.digest("tickets", {
filters: { status: "open" },
});
if (sampled) {
// The answer describes `analyzed` of `matched` rows. Say so — don't imply it read them all.
}The important idea: enrich once, query forever
The other verbs answer a question. annotate changes the economics: it runs the model once
per row and stores the answer in an ordinary column. Every question after that is plain SQL —
free, instant, and exact on ten million rows.
// Once, in the background
await sdk.ai.annotate("tickets", {
into: "theme", // an ordinary column you created normally
from: ["subject", "body"],
instruction: "Classify the support theme.",
labels: ["billing", "shipping", "bug", "other"],
});
// Forever after — no AI in the loop at all
await sdk.aggregate("tickets", {
groupBy: [{ column: "created_at", truncate: "month" }, { column: "theme" }],
measures: [{ op: "count" }],
});This is how to answer "big data" questions honestly: you never run a model over a huge table,
you run it once over each new row and let SQL do the rest. Re-running annotate is cheap —
rows whose source text has not changed are skipped entirely.
You can also configure this in the console instead of in code — see AI-derived columns. The platform then fills the column in and keeps it current on its own.
Rules worth knowing before you rely on it
A person always beats the model. If someone edits a derived value by hand, it is pinned: no re-run, and no model change, will ever overwrite it. Editing one of the column's source values releases that pin, because that is the person asking for a fresh read.
A derived column cannot read a redacted column. This is refused when you save the
configuration, not silently at run time. There is no good outcome available otherwise: the model
either receives [redacted] and produces meaningless labels, or your redacted values are sent to
a third party.
Stored values always use the fast model. annotate and redact take no tier, so a single
column never ends up with values from two different models — which would make its own counts
incomparable.
Nothing is spent in a dry run. Testing a handler never bills your organization; the intent is recorded and no provider call is made.
Choosing a model — you don't, in code
Handlers never name a model. Every sdk.ai call runs on one of two tiers, and your
organization's AI connection decides which model each one is:
| Tier | Used for | Pass |
|---|---|---|
fast | Most work: labeling, extraction, summaries. The default. | nothing, or tier: "fast" |
default | A harder one-off question that deserves your more capable model. | tier: "default" |
const { text } = await sdk.ai.digest("contracts", { tier: "default" });Both models are set once, in Integrations → AI providers on the connection. Leave them blank
to use the platform's current choice. Because no handler names a model, a model being retired or
your organization switching providers is a single edit there — not an edit to every handler.
Passing model is refused with AI_TIER_INVALID.
If the model chosen for a tier is retired, calls on that tier stop with AI_MODEL_UNAVAILABLE
until someone picks another one on the connection. They are never quietly moved to a different
model.
Costs
The fast model is a low-cost one on purpose, because a frontier model costs several times more for no measurable gain on labeling. On today's platform defaults, labeling 100,000 typical rows costs roughly $20–30 at list price on a hosted provider, and nothing per token on a self-hosted model. The column editor shows the figure for your connection before you switch anything on. After the first pass, cost is near zero, because only changed rows are re-read.
Every call is recorded — model, token counts, and cost — and counts against a per-plan AI spend limit, so a handler cannot quietly run up a bill.
Semantic search
embed computes and stores vectors; similar finds rows that mean something alike.
await sdk.ai.embed("tickets", { columns: ["subject", "body"] });
const { matches } = await sdk.ai.similar("tickets", { to: ticketUid, limit: 5 });
// [{ uid: "0199a3c2-…", score: 0.91 }, ...]Two honest limits. Embeddings require a provider that offers them — OpenAI does, Anthropic does
not — and you get a clear AI_EMBEDDINGS_UNSUPPORTED rather than a confusing failure if yours
cannot. And similar scans one table's embedded rows rather than using an approximate index, so
it is comfortable to roughly 100,000 embedded rows per table. Past that you want a dedicated
vector store, which slots in behind the same call.