listSources
never executesDiscovery. Connected sources with typed fields, capability modules, function signatures — three modes from one input, so specs use exact names.
For agents — the governed data tool
SQAI gives a tool-calling model read-only access to structured data through three governed tools. The model authors typed intent. A contract and your policy check it. A deterministic engine executes it. Every answer returns with a hash that replays it.
IOne turn of the loop
“Which region had the highest total revenue?”Plans the steps. Authors intent. Never touches the data.
{ version:"1", kind:"query", spec:{ metric:"revenue", aggregation:"sum", group_by:"region", source:"sales" } }contracthash-pinned capability contractpolicyyour allow-lists, checked before executionenginedeterministic, read-only executestatus: ok · needs_clarification · rejected · erroreast — 2,130.50 · plan_hash f87610d8afeb…okExits the loop. The rows or the value — and the hash that replays them.
needs_clarification ↺Re-enters the loop. A question and the candidates it found — the model refines the spec and calls again. It never guesses a column.
rejected and error ride the same return lane — typed results the model can read, not exceptions that abort the run.
IIThe toolset
sqai.tools() returns exactly three tools — a deliberately small surface. Discovery and dry runs never execute; execution happens in exactly one place.
listSourcesDiscovery. Connected sources with typed fields, capability modules, function signatures — three modes from one input, so specs use exact names.
queryDataOne versioned input — a discriminated union on kind: a query or a computation. Four outcomes, always typed, never a throw.
explainQueryResolves and validates an intent without running it. The model uses it to debug clarifications; you use it to preview a plan.
The set drops straight into generateText or streamText — typed, assignable with no cast. Vercel AI SDK setup →
IIIThe error contract
An exception aborts an agent run. So nothing here throws: ambiguity, refusal, and failure come back as typed statuses the model can read — and correct — inside its step budget.
retryable marks the transient ones — network_error · timeout · rate_limited · service_unavailable
queryData — the four outcomes
okThe result: rows or value, provenance hashes, declared truncation.plan_hash · invocation_hash · computation_hash · truncatedneeds_clarificationThe intent was ambiguous. A question plus the candidates — never a guess.question · candidates · explanationrejectedThe resolver refused the intent. Nothing executed.rejection_reason · candidates · explanationerrorA structured failure with a stable code — the model reads it and adjusts.code · message · retryable · nearest_matches?Every branch is data. The loop keeps its turn.
The principle
policy in code · no policy field in any tool input · narrow-only
IVThe principle, applied
The industry learned this in production — an autonomous coding agent famously deleted a live database. SQAI’s answer is structural, not behavioral: the exposed surface is read-only by construction. Write and non-deterministic categories are never generated into it, and no flag, policy, or model input turns them back on.
VThe wiring
One package wraps the client into tools. Sources connect lazily on the first call; the first computation provisions the runtime once, then stays warm — 0.83 ms measured.
import { generateText } from "ai";
import { createSQAI } from "@thyn-ai/sqai-ai-sdk";
const sqai = createSQAI({
sources: [{ data: "./data/sales.csv", name: "sales" }],
});
const { text, steps } = await generateText({
model, // any AI SDK model
tools: sqai.tools(), // listSources · queryData · explainQuery
prompt: "Which region had the highest total revenue?",
});Server-side only — on Next.js, keep the Node runtime, not Edge.
What reaches the model
Truncation is always declared — truncated: true, returned_rows < total_rows. Never silent. Full results stay retrievable in application code by result_id.
VIAgentic analytics
Agentic analytics fails in known ways: malformed tool calls, models doing their own arithmetic, plausible numbers nobody can verify. Each one is answered structurally, not with a better prompt.
One versioned input, validated before anything runs. Malformed or ambiguous intent returns a typed status — the loop continues instead of crashing.
Not here. The engine computes. On a 16-question quantitative set, the same model went 0/16 to 16/16 once the engine did the computing.
Every answer carries its hash. Replay the same intent against the same data and the hash matches — byte-identical in TypeScript and Python.
A loop multiplies latency. The query plane runs in-process at sub-millisecond; warm compute measured at 0.83 ms.
VIIQuestions
Don’t filter SQL — don’t generate it. Connect sources to createSQAI() and hand the model sqai.tools(): the surface it reaches is 4,574 read-only capabilities plus your sources, with no write path by construction. There is no write category to block, and no model input that re-enables one.
queryData returns needs_clarification with a question and the candidate fields it found. It never guesses a column. The model answers the question and calls again — one more turn of the loop.
No. Every failure is a structured result with a stable code, a message, and a retryable flag. Ambiguity, refusal, policy denial, and transient faults all come back as data the model can read.
No. allowedSources, allowedFields and allowedFunctions live in code, set at createSQAI() time and checked before execution. No tool input schema contains a policy field; naming a denied capability returns a structured policy_denied error.
Yes — @thyn-ai/sqai-ai-sdk ships the three tools as a typed tool set for generateText and streamText, with ai ^7.0.0 and zod ^4.0.0 as peers, Node 20 or newer, server-side. The underlying client is the same SDK you can wire into any agent loop.