Skip to content

Commit 630a4b7

Browse files
author
Philip Z
committed
feat: add typed TeaQL tools for Vercel AI SDK
0 parents  commit 630a4b7

20 files changed

Lines changed: 4712 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
verify:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: 22
18+
cache: npm
19+
- run: npm ci
20+
- run: npm run check
21+
- run: npm test
22+
- run: npm run build
23+
- run: npm run example

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules/
2+
dist/
3+
coverage/
4+
*.log
5+
.env
6+
.env.local
7+
examples/*/*.db

LICENSE

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Apache License
2+
Version 2.0, January 2004
3+
http://www.apache.org/licenses/
4+
5+
Copyright 2026 TeaQL
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.

README.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# TeaQL Agent Data Tools for Vercel AI SDK
2+
3+
Generated, typed, auditable business tools for AI SDK agents—without exposing raw SQL.
4+
5+
> Don't give your AI agent unrestricted SQL. Give it a typed business language.
6+
7+
`@teaql/ai-sdk` converts an explicit allowlist of TeaQL business capabilities into native [Vercel AI SDK](https://ai-sdk.dev/) tools. The model receives business operations and their schemas. A trusted TeaQL `UserContext`, database resources, authorization state, and internal errors remain on the server.
8+
9+
## Why
10+
11+
| Raw SQL agent | TeaQL agent tools |
12+
| --- | --- |
13+
| Model guesses tables and joins | Model selects named business capabilities |
14+
| Broad database access | Explicit capability allowlist |
15+
| Untyped rows | Schema-validated input and output |
16+
| Authorization depends on prompts | Trusted server-side `context` |
17+
| Mutations are difficult to govern | AI SDK approval plus TeaQL audit semantics |
18+
| Database errors may leak | Safe public errors and observable internal failures |
19+
| Database-specific behavior | TeaQL domain semantics can span seven runtimes |
20+
21+
This package does not replace the AI SDK agent loop, model providers, UI, or streaming. It supplies the governed business-data layer beneath those features.
22+
23+
## Install
24+
25+
```bash
26+
npm install @teaql/ai-sdk @teaql/teaql ai zod
27+
```
28+
29+
Node.js 22 or newer and AI SDK 7 are required by the initial release.
30+
31+
## Define business capabilities
32+
33+
Capabilities are an explicit allowlist. The adapter intentionally does not expose every entity and CRUD operation automatically.
34+
35+
```ts
36+
import { defineTeaQLCapability } from '@teaql/ai-sdk';
37+
import { z } from 'zod';
38+
39+
const searchSchools = defineTeaQLCapability({
40+
name: 'searchSchools',
41+
description: 'Find schools by governed business criteria.',
42+
inputSchema: z.object({
43+
schoolType: z.enum(['PRIMARY', 'SECONDARY']),
44+
name: z.string().optional(),
45+
}),
46+
risk: 'read',
47+
execute: async ({ context, input }) =>
48+
Q.schools()
49+
.withSchoolType(input.schoolType)
50+
.withNameContaining(input.name)
51+
.comment('AI SDK tool: searchSchools')
52+
.purpose('Search schools requested by the authenticated user')
53+
.executeForList(context),
54+
});
55+
```
56+
57+
The exact generated Q API follows the selected TeaQL model and generator version; capability definitions are ordinary typed application code and compile against it.
58+
59+
## Create native AI SDK tools
60+
61+
```ts
62+
import { UserContext } from '@teaql/teaql';
63+
import { ToolLoopAgent } from 'ai';
64+
import { createTeaQLTools } from '@teaql/ai-sdk';
65+
66+
const context = new UserContext()
67+
.insertResource('dataService', dataService)
68+
.insertResource('authorization', authorization);
69+
70+
const agent = new ToolLoopAgent({
71+
model: 'openai/gpt-5.4',
72+
instructions: 'Use only the provided business tools. Never invent SQL.',
73+
tools: createTeaQLTools({
74+
context,
75+
capabilities: [searchSchools, updateSchoolContactPhone],
76+
}),
77+
});
78+
```
79+
80+
Create `context` on the server for each request or session. Never accept it from model output or a browser payload.
81+
82+
## Govern writes
83+
84+
```ts
85+
const updateSchoolContactPhone = defineTeaQLCapability({
86+
name: 'updateSchoolContactPhone',
87+
description: 'Update a school phone after explicit user approval.',
88+
inputSchema: z.object({
89+
schoolId: z.number().int().positive(),
90+
contactPhone: z.string(),
91+
auditReason: z.string().min(8),
92+
}),
93+
risk: 'write',
94+
needsApproval: true,
95+
execute: async ({ context, input }) => {
96+
const school = await Q.schools()
97+
.withId(input.schoolId)
98+
.comment('Load school for approved contact update')
99+
.purpose(input.auditReason)
100+
.executeForOne(context);
101+
102+
return school
103+
.updateContactPhone(input.contactPhone)
104+
.auditAs(input.auditReason)
105+
.save(context);
106+
},
107+
});
108+
```
109+
110+
AI SDK approval controls whether the agent may execute the tool. TeaQL audit and runtime authorization still apply when execution begins. Approval is not a replacement for runtime security.
111+
112+
## Observe execution without leaking internals
113+
114+
```ts
115+
const tools = createTeaQLTools({
116+
context,
117+
capabilities,
118+
onEvent: event => telemetry.record(event),
119+
mapError: (_error, capability) =>
120+
`${capability.name} could not be completed. Review the request or contact support.`,
121+
});
122+
```
123+
124+
Lifecycle events contain capability name, risk, tool-call ID, timing, and the internal error on the server. Inputs are excluded by default because they may contain sensitive business data. The default model-visible error never includes the original database error.
125+
126+
## Run the local demonstration
127+
128+
The repository includes a deterministic, no-API-key school-management demonstration. It uses an in-memory SQLite resource inside the trusted `UserContext` to show the security boundary, approval metadata, optimistic version change, audit record, and model-visible tools.
129+
130+
```bash
131+
npm install
132+
npm run example
133+
```
134+
135+
The SQLite repository is deliberately small and handwritten so the example is self-contained. A generated TeaQL project replaces that repository implementation with its generated Q, entity, Save, and Runtime Module APIs; the AI SDK adapter remains unchanged.
136+
137+
See [`examples/school-agent`](examples/school-agent).
138+
139+
## Security model
140+
141+
- Capabilities are deny-by-absence: only definitions passed to `createTeaQLTools` exist.
142+
- `allow` can narrow the registered capabilities for a particular user or agent.
143+
- `context` is captured by the server-side execute closure and is not part of `inputSchema`.
144+
- Tool risk is metadata for policy and telemetry; applications must still enforce authorization in the runtime.
145+
- Writes can request AI SDK approval, but must also use TeaQL audit and validation.
146+
- Internal failures are available to server telemetry and hidden from the model by default.
147+
- Capability names and duplicates are validated during startup.
148+
149+
## Current scope
150+
151+
The initial release is a runtime adapter for explicit TypeScript capability definitions. Planned generator work will produce capability definitions, schemas, agent guidance, and conformance fixtures from a TeaQL model. MCP adapters can expose the same capability manifest to Java, Rust, TypeScript, Swift, Python, .NET, and Go runtimes.
152+
153+
## Related projects
154+
155+
- [TeaQL](https://teaql.io)
156+
- [TeaQL TypeScript Runtime](https://github.com/teaql/teaql-ts)
157+
- [TeaQL Code Generator](https://github.com/teaql/teaql-code-gen)
158+
- [TeaQL Agent Kit](https://github.com/teaql/teaql-agent-kit)
159+
- [TeaQL Conformance](https://github.com/teaql/teaql-conformance)
160+
- [Vercel AI SDK](https://github.com/vercel/ai)
161+
162+
## License
163+
164+
Apache-2.0

eslint.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import eslint from '@eslint/js';
2+
import tseslint from 'typescript-eslint';
3+
4+
export default tseslint.config(
5+
eslint.configs.recommended,
6+
...tseslint.configs.recommended,
7+
{
8+
ignores: ['dist', 'coverage', 'node_modules'],
9+
},
10+
);

examples/school-agent/ai-sdk.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { UserContext } from '@teaql/teaql';
2+
import { ToolLoopAgent } from 'ai';
3+
4+
import { createTeaQLTools } from '../../src/index.js';
5+
import { schoolCapabilities } from './capabilities.js';
6+
import type { SchoolRepository } from './database.js';
7+
8+
export function createSchoolAgent(repository: SchoolRepository) {
9+
// Construct this trusted context on the server for each request or session.
10+
const context = new UserContext().insertResource('schoolRepository', repository);
11+
12+
return new ToolLoopAgent({
13+
model: 'openai/gpt-5.4',
14+
instructions:
15+
'Use only the provided business tools. Never invent SQL or database field names.',
16+
tools: createTeaQLTools({ context, capabilities: schoolCapabilities }),
17+
});
18+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { defineTeaQLCapability } from '../../src/index.js';
2+
import { z } from 'zod';
3+
4+
import type { SchoolRepository } from './database.js';
5+
6+
export const schoolCapabilities = [
7+
defineTeaQLCapability({
8+
name: 'findSchoolsMissingContact',
9+
description:
10+
'Find schools of a given type whose contact phone is explicitly null. This is read-only.',
11+
inputSchema: z.object({
12+
schoolType: z
13+
.enum(['PRIMARY', 'SECONDARY'])
14+
.describe('The governed TeaQL school type, not a database value.'),
15+
}),
16+
outputSchema: z.array(
17+
z.object({
18+
id: z.number(),
19+
name: z.string(),
20+
schoolType: z.enum(['PRIMARY', 'SECONDARY']),
21+
contactPhone: z.string().nullable(),
22+
version: z.number(),
23+
}),
24+
),
25+
risk: 'read',
26+
execute: ({ context, input }) =>
27+
context
28+
.requireResource<SchoolRepository>('schoolRepository')
29+
.findMissingContact(input.schoolType),
30+
}),
31+
defineTeaQLCapability({
32+
name: 'updateSchoolContactPhone',
33+
description:
34+
'Update one school contact phone with an explicit audit reason. Requires user approval.',
35+
inputSchema: z.object({
36+
schoolId: z.number().int().positive(),
37+
contactPhone: z.string().min(5).max(40),
38+
auditReason: z.string().min(8).max(200),
39+
}),
40+
outputSchema: z.object({
41+
id: z.number(),
42+
name: z.string(),
43+
schoolType: z.enum(['PRIMARY', 'SECONDARY']),
44+
contactPhone: z.string().nullable(),
45+
version: z.number(),
46+
}),
47+
risk: 'write',
48+
needsApproval: true,
49+
execute: ({ context, input }) =>
50+
context
51+
.requireResource<SchoolRepository>('schoolRepository')
52+
.updateContactPhone(input.schoolId, input.contactPhone, input.auditReason),
53+
}),
54+
] as const;

examples/school-agent/database.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Database from 'better-sqlite3';
2+
3+
export interface School {
4+
readonly id: number;
5+
readonly name: string;
6+
readonly schoolType: 'PRIMARY' | 'SECONDARY';
7+
readonly contactPhone: string | null;
8+
readonly version: number;
9+
}
10+
11+
export interface SchoolRepository {
12+
findMissingContact(schoolType: School['schoolType']): School[];
13+
updateContactPhone(id: number, contactPhone: string, auditReason: string): School;
14+
auditLog(): readonly { action: string; entityId: number; reason: string }[];
15+
}
16+
17+
export function createSchoolRepository(): SchoolRepository {
18+
const database = new Database(':memory:');
19+
database.exec(`
20+
CREATE TABLE school (
21+
id INTEGER PRIMARY KEY,
22+
name TEXT NOT NULL,
23+
school_type TEXT NOT NULL,
24+
contact_phone TEXT,
25+
version INTEGER NOT NULL
26+
);
27+
CREATE TABLE audit_log (
28+
action TEXT NOT NULL,
29+
entity_id INTEGER NOT NULL,
30+
reason TEXT NOT NULL
31+
);
32+
INSERT INTO school VALUES
33+
(1, 'Riverside Secondary School', 'SECONDARY', NULL, 1),
34+
(2, 'Northwind Primary School', 'PRIMARY', '+1-555-0102', 1),
35+
(3, 'Lakeside Secondary School', 'SECONDARY', NULL, 1);
36+
`);
37+
38+
return {
39+
findMissingContact(schoolType) {
40+
return database
41+
.prepare(
42+
`SELECT id, name, school_type AS schoolType,
43+
contact_phone AS contactPhone, version
44+
FROM school
45+
WHERE school_type = ? AND contact_phone IS NULL
46+
ORDER BY id`,
47+
)
48+
.all(schoolType) as School[];
49+
},
50+
updateContactPhone(id, contactPhone, auditReason) {
51+
const transaction = database.transaction(() => {
52+
const result = database
53+
.prepare(
54+
`UPDATE school
55+
SET contact_phone = ?, version = version + 1
56+
WHERE id = ?`,
57+
)
58+
.run(contactPhone, id);
59+
if (result.changes !== 1) throw new Error(`School row not found: ${id}`);
60+
database
61+
.prepare('INSERT INTO audit_log VALUES (?, ?, ?)')
62+
.run('updateContactPhone', id, auditReason);
63+
});
64+
transaction();
65+
return database
66+
.prepare(
67+
`SELECT id, name, school_type AS schoolType,
68+
contact_phone AS contactPhone, version
69+
FROM school WHERE id = ?`,
70+
)
71+
.get(id) as School;
72+
},
73+
auditLog() {
74+
return database
75+
.prepare(
76+
`SELECT action, entity_id AS entityId, reason
77+
FROM audit_log ORDER BY rowid`,
78+
)
79+
.all() as { action: string; entityId: number; reason: string }[];
80+
},
81+
};
82+
}

0 commit comments

Comments
 (0)