Build a Safe SQL Agent Without Giving the Model SQL Access
The fastest way to connect an AI agent to application data is often a generic SQL tool. Give the model a schema, accept a SQL string, run it, and return the rows.
That is also where a prototype can quietly become a production security and maintenance problem.
The model must understand physical table names, joins, nullable columns, tenant boundaries, authorization rules, and mutation policy. Database details become part of the prompt contract. A schema change can invalidate that contract, and a broadly capable SQL tool exposes much more authority than most business tasks require.
TeaQL takes a different approach:
Don't give your AI agent unrestricted SQL. Give it a typed business language.
The open-source @teaql/ai-sdk
adapter converts an explicit allowlist of business capabilities into native
Vercel AI SDK tools. The model sees operations such as
findSchoolsMissingContact and updateSchoolContactPhone. The server keeps the
TeaQL context, runtime resources, authorization state, credentials, and
internal failures.
The missing layer beneath an agent SDK
The Vercel AI SDK already provides the agent loop, tool calling, streaming, model-provider integration, and UI primitives. TeaQL does not reproduce those features. It supplies the governed business-data layer beneath them:
User conversation
-> Vercel AI SDK agent
-> typed TeaQL business tools
-> UserContext, policy, audit and runtime services
-> database
A generic SQL tool exposes an implementation mechanism:
const executeSql = tool({
description: "Execute SQL against the application database",
inputSchema: z.object({ sql: z.string() }),
execute: async ({ sql }) => database.query(sql),
});
A TeaQL capability exposes an application operation:
const findSchoolsMissingContact = defineTeaQLCapability({
name: 'findSchoolsMissingContact',
description:
'Find schools of a given type whose contact phone is explicitly null.',
inputSchema: z.object({
schoolType: z.enum(['PRIMARY', 'SECONDARY']),
}),
risk: 'read',
execute: ({ context, input }) =>
context
.requireResource<SchoolRepository>('schoolRepository')
.findMissingContact(input.schoolType),
});
The second tool does not ask the model to invent a table name, encode an enum as the right database value, or decide which columns are safe to return. Its name and schema describe a bounded business capability.
Deny by absence
createTeaQLTools receives the complete set of capabilities available to one
agent. A capability that is not passed to the function does not exist in the
AI SDK toolset.
const tools = createTeaQLTools({
context,
capabilities: [
findSchoolsMissingContact,
updateSchoolContactPhone,
],
});
An optional runtime allowlist can narrow that set for a particular user or agent:
const readOnlyTools = createTeaQLTools({
context,
capabilities: schoolCapabilities,
allow: ['findSchoolsMissingContact'],
});
Unknown allowlist names and duplicate capability names fail during startup. They do not silently produce a weaker or unexpectedly empty policy.
This is intentionally different from generating five CRUD tools for every entity. Large domains can contain hundreds of entities. Automatically exposing every operation creates tool-selection noise and grants the agent authority it does not need. TeaQL models the smaller set of operations that make sense for the agent's job.
The model never receives UserContext
The input schema is model-visible. The TeaQL UserContext is not.
const context = new UserContext()
.insertResource('dataService', dataService)
.insertResource('authorization', authorization);
const tools = createTeaQLTools({ context, capabilities });
context is captured in the server-side execute closure. It carries trusted
runtime resources, identity and policy selected by the application. The model
cannot construct it, replace its data service, choose another tenant, or add a
permission through tool input.
This follows a broader TeaQL API rule: business execution receives one trusted
context; process-level runtime ownership and provider installation are not
mixed into model-generated parameters.
Approval and authorization solve different problems
A write capability can request AI SDK approval:
const updateSchoolContactPhone = defineTeaQLCapability({
name: 'updateSchoolContactPhone',
description:
'Update one school contact phone with an explicit audit reason.',
inputSchema: z.object({
schoolId: z.number().int().positive(),
contactPhone: z.string().min(5).max(40),
auditReason: z.string().min(8).max(200),
}),
risk: 'write',
needsApproval: true,
execute: async ({ context, input }) => {
const school = await loadSchool(context, input.schoolId);
return school
.updateContactPhone(input.contactPhone)
.auditAs(input.auditReason)
.save(context);
},
});
Approval asks whether the user authorizes this proposed tool call. Authorization asks whether the authenticated user is allowed to perform the operation at all. Audit records what happened and why. Validation determines whether the proposed state is legal.
These controls reinforce one another, but they are not interchangeable:
Agent proposes a write
-> AI SDK approval
-> TeaQL runtime authorization
-> domain validation
-> audited save
-> persisted result
A prompt that says "do not update restricted schools" is useful guidance. It is not a runtime permission boundary.
Preserve the persisted result
The included school example starts with a secondary school whose contact phone
is explicitly null and whose optimistic version is 1. After an approved,
audited update, the tool returns the persisted object with the new phone and
version 2:
Persisted result: {
id: 1,
name: 'Riverside Secondary School',
schoolType: 'SECONDARY',
contactPhone: '+1-555-0100',
version: 2
}
Returning the authoritative persisted entity matters when a database assigns an ID, a trigger or default supplies a value, or optimistic versioning changes state. A write tool should not reconstruct a record from its input and pretend that it represents the database result.
TeaQL also distinguishes loaded, explicit null, and not-loaded states. A
partially projected entity cannot safely collapse those states into ordinary
TypeScript undefined values.
Observe failures without leaking them
Tool execution emits lifecycle events that can feed structured logs or an OpenTelemetry adapter:
const tools = createTeaQLTools({
context,
capabilities,
onEvent: event => telemetry.record(event),
});
The default events include capability name, risk, tool-call ID and timing. Inputs are excluded because they may contain sensitive business data.
When capability execution fails, server telemetry receives the original error. The model receives a safe message such as:
The updateSchoolContactPhone business operation could not be completed.
Applications can map public error messages, but raw connection strings, SQL, credentials and internal exception details remain on the server.
Native Vercel AI SDK integration
The resulting object is an AI SDK ToolSet and can be passed directly to an
agent:
import { UserContext } from '@teaql/teaql';
import { ToolLoopAgent } from 'ai';
import { createTeaQLTools } from '@teaql/ai-sdk';
const context = new UserContext()
.insertResource('schoolRepository', repository);
const agent = new ToolLoopAgent({
model: 'openai/gpt-5.4',
instructions:
'Use only the provided business tools. Never invent SQL or database fields.',
tools: createTeaQLTools({
context,
capabilities: schoolCapabilities,
}),
});
The adapter uses AI SDK schemas, metadata, execution and approval semantics. It does not require the application to adopt a second agent loop.
Run the demonstration without an API key
The repository contains a deterministic school-management demonstration. It uses in-memory SQLite, so no external database, model API key, or signup is required:
git clone https://github.com/teaql/teaql-ai-sdk.git
cd teaql-ai-sdk
npm install
npm run example
The demonstration prints:
- the two model-visible capabilities;
- approval metadata on the write tool;
- schools whose contact value is explicitly null;
- lifecycle events;
- the persisted version change;
- the audit record.
The small SQLite repository is handwritten to keep the adapter demonstration self-contained. In a generated TeaQL application, its implementation is replaced by generated Q requests, entities, Save behavior and a Runtime Module. The AI SDK adapter and its security boundary remain the same.
The npm package is prepared as @teaql/ai-sdk; while registry publication is
being completed, the GitHub repository above is the authoritative installation
and source location.
Start with TypeScript, preserve cross-runtime semantics
The initial adapter is TypeScript-first because the Vercel AI SDK is a TypeScript ecosystem. TeaQL's larger responsibility is preserving one domain language and equivalent runtime behavior across Java, Rust, TypeScript, Swift, Python, .NET and Go.
The intended generation path is:
TeaQL domain model
-> runtime entities and typed queries
-> explicit agent capability manifest
-> AI SDK tools
-> MCP tools
-> agent usage Markdown
-> executable TeaQL Agent Kit verification
A TypeScript AI SDK application can execute capabilities locally through the TeaQL TypeScript Runtime. The same capability manifest can later reach another TeaQL runtime through MCP or the TeaQL Federal Protocol without teaching the model seven unrelated database APIs.
What is available today
The first public repository includes:
- native AI SDK
ToolSetcreation; - typed input and output schemas;
- explicit capability and per-agent allowlists;
- trusted server-side
UserContextinjection; - read, write and privileged risk metadata;
- approval support;
- safe error mapping;
- lifecycle events for telemetry;
- configuration validation;
- five automated boundary tests;
- a no-key SQLite demonstration;
- a passing public GitHub Actions workflow.
Generator-produced capability definitions, the hosted interactive demo, OpenTelemetry export and cross-runtime MCP execution remain follow-up work. The project documents those limits rather than presenting a thin adapter as a finished enterprise security system.
Try it and challenge the boundary
The most useful feedback is not whether another generic tool wrapper can be added. It is whether the capability boundary remains understandable and enforceable in a real application:
- Which business operations should become tools?
- When should an operation require approval?
- Which data must never appear in a model-visible schema or trace?
- How should capability manifests evolve with a domain model?
- Which negative conformance tests would make the security claim credible?
The code, tests and runnable example are available at
teaql/teaql-ai-sdk. Related work is
maintained in the TeaQL TypeScript Runtime,
TeaQL Agent Kit, and
TeaQL Conformance.
