Handlers

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

VerbAnswers
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.annotateSame as classify, but stores the label in a column
sdk.ai.redactMask personal details inside stored text
sdk.ai.embed / sdk.ai.similarSearch 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:

TierUsed forPass
fastMost work: labeling, extraction, summaries. The default.nothing, or tier: "fast"
defaultA 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.

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.

On this page