For agents — the governed data tool

AI agent tools
that answer with proof.

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

the request entersWhich region had the highest total revenue?
any tool-calling modelThe model

Plans the steps. Authors intent. Never touches the data.

tool call — typed intent, never SQL{ version:"1", kind:"query", spec:{ metric:"revenue", aggregation:"sum", group_by:"region", source:"sales" } }
the governed toolSQAI
  1. contracthash-pinned capability contract
  2. policyyour allow-lists, checked before execution
  3. enginedeterministic, read-only execute
typed status — never a throwstatus: ok · needs_clarification · rejected · error
the answer leaves — with its hasheast — 2,130.50 · plan_hash f87610d8afeb…
ok

Exits 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

Three tools. One executes.

sqai.tools() returns exactly three tools — a deliberately small surface. Discovery and dry runs never execute; execution happens in exactly one place.

listSources

never executes

Discovery. Connected sources with typed fields, capability modules, function signatures — three modes from one input, so specs use exact names.

sources · capabilitySearch · module
ops: sum avg count min max eq in gt gte lt lte is_null is_not_null

queryData

the one that executes

One versioned input — a discriminated union on kind: a query or a computation. Four outcomes, always typed, never a throw.

version "1" · kind: query | computation
ok · needs_clarification · rejected · error

explainQuery

dry run — never executes

Resolves and validates an intent without running it. The model uses it to debug clarifications; you use it to preview a plan.

preview invocation_hash ≠ executed hash

The set drops straight into generateText or streamText — typed, assignable with no cast. Vercel AI SDK setup

IIIThe error contract

Tools never throw.

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 · truncated
needs_clarificationThe intent was ambiguous. A question plus the candidates — never a guess.question · candidates · explanation
rejectedThe resolver refused the intent. Nothing executed.rejection_reason · candidates · explanation
errorA 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

Treat the model as
an untrusted client.

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.

What the model sends
Typed intent only — a versioned spec. No SQL string, no code, no connection handle.
What it can never send
Policy. allowedSources, allowedFields and allowedFunctions are fixed in createSQAI() config — no tool input schema contains a policy field, so a request can narrow the surface but never widen it.
When it asks anyway
A structured denial — policy_denied_source, policy_denied_field, policy_denied_function. Non-retryable, model-readable, and the run continues.
Full policy model

VThe wiring

Three lines in. Bounded by default.

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?",
});
ai ^7.0.0zod ^4.0.0node ≥ 20runtime = "nodejs"

Server-side only — on Next.js, keep the Node runtime, not Edge.

What reaches the model

maxRowsToModel
25rows the model sees, out of everything the engine executed
maxCellsToModel
250cell budget — whole rows are dropped to fit; a partial row is never shown
maxBytesToModel
32,000byte budget for the model-visible value
maxExecutionRows
1,000hard cap the engine executes — defaultLimit 100 when a spec omits limit

Truncation is always declared — truncated: true, returned_rows < total_rows. Never silent. Full results stay retrievable in application code by result_id.

VIAgentic analytics

Analysis an agent can defend.

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.

01Invalid tool calls

One versioned input, validated before anything runs. Malformed or ambiguous intent returns a typed status — the loop continues instead of crashing.

02The model does the math

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.

03Unverifiable numbers

Every answer carries its hash. Replay the same intent against the same data and the hash matches — byte-identical in TypeScript and Python.

04Latency stacking

A loop multiplies latency. The query plane runs in-process at sub-millisecond; warm compute measured at 0.83 ms.

VIIQuestions

For agents — the questions

How do I give an AI agent read-only access to a database?

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.

What happens when the agent’s request is ambiguous?

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.

Do the tools ever throw an exception?

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.

Can the model widen its own permissions?

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.

Does it work with the Vercel AI SDK?

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.