The authorization layer for AI agents.
Row-level security, field-level permissions, and per-user access control for LLM agents — enforced server-side in code, not prompts. Give your AI agent access to your database without giving it access to everything.
- Auto-generated typed tools — reads your ORM schema and builds
query_,get_,create_,update_,delete_,aggregate_tools per resource - Row filters that can't be bypassed —
{ tenant_id: ctx.tenant.id }is AND-ed server-side into every query, regardless of what the model sends - Field-level control — mark fields
@vistal:sensitiveand they never appear in tool schemas, arguments, or results - Tool suppression —
delete: falsemeans no delete tool is generated, nothing to call - Multi-provider — Vercel AI SDK, Anthropic, OpenAI, Gemini, or bring your own formatter
- Multi-tenant by default — one policy function handles all roles; context drives what each user sees
const tools = await vistal.tools.vercel(ctx)
await generateText({ model, tools, maxSteps: 5, prompt })The agent sees only what the current user is allowed to see. No SQL generation. No prompt-based permissions. No per-endpoint wrappers.
Most agents reach your data through one of these:
LLM → SQL LLM → ORM LLM → API endpoints
…and authorization usually lives in the prompt:
"Only return data for the current tenant."
That holds right up until the model ignores the instruction, a prompt injection lands, a tool is misconfigured, or someone forgets a filter. A prompt is not a security boundary. One slip leaks customer data.
With vistal, permissions live in code — not prompts.
vistal.policy("order", (ctx) => ({
read: { tenant_id: ctx.tenant.id },
}))That filter is AND-ed into every read, server-side, after the tool call is parsed. The model cannot widen it, override it, or talk its way around it — the filtered query is the only query that runs.
"Summarize all orders. For each delivered order, show the items purchased."
| Alice · admin | Bob · support | Carol · admin, tenant-β | |
|---|---|---|---|
| Tools visible | query, get, create, update, aggregate | query, get, aggregate | query, get, create, update, aggregate |
| Row filter | tenant_id = alpha |
tenant_id = alpha |
tenant_id = beta |
| Hidden fields | — | user_id |
— |
| Customer relation | ✓ | ✗ blocked | ✓ |
| Orders returned | #1, #3 | #1, #3 | #5, #6 |
Alice gets full output. Bob gets no customer link and user_id stripped. Carol only sees her tenant — tenant-alpha orders are structurally invisible to her. All from one policy function, no branching in your prompt.
import { createVistal } from "@vistal/prisma"
const vistal = createVistal(prisma, { defaultPolicy: "deny-all" })
vistal.policy("order", (ctx) => ({
read: { tenant_id: ctx.tenant.id }, // row filter — AND-ed into every read
write: { tenant_id: ctx.tenant.id }, // force-injected on INSERT, guards UPDATE WHERE
delete: false, // delete_order tool never generated
fields: { deny: ctx.user.role === "support" ? ["user_id"] : [] },
relations: { customer: ctx.user.role === "admin", items: true },
}))That policy produces exactly these tools:
admin support
──────────────────────────────── ────────────────────────────────
query_order ← tenant filter query_order ← tenant filter
get_order ← tenant filter get_order ← tenant filter
create_order ← tenant injected create_order ← tenant injected
update_order ← tenant guard update_order ← tenant guard
aggregate_order aggregate_order
↳ user_id stripped from results
✗ delete_order not generated for either
Connect to your agent in one line:
const tools = await vistal.tools.vercel(ctx)
const { text } = await generateText({ model, tools, maxSteps: 8, prompt })LLM
↓ tool call (no SQL, just arguments)
vistal policy engine ← row filters, write injection, field stripping, tool suppression
↓
your ORM
↓
database
The model never writes a query. It calls a typed tool with arguments; vistal resolves that into an ORM operation, applies the policy before execution, and runs it. Enforcement happens on the server, in your process — not in the prompt and not on the model's honor.
npm install @vistal/core @vistal/prisma| Package | Contents |
|---|---|
@vistal/core |
Zero-dependency core — policies, tool generation, IR |
@vistal/prisma |
Prisma adapter + schema introspection (requires Prisma 5+) |
ai |
Optional — only needed for vistal.tools.vercel() |
import { PrismaClient } from "@prisma/client"
import { createVistal } from "@vistal/prisma"
const prisma = new PrismaClient()
const vistal = createVistal(prisma, { defaultPolicy: "deny-all" })createVistal infers the resource types from your Prisma client — policy keys are type-checked, so a typo is a compile error. Pass schemaPath if your schema isn't at the default ./prisma/schema.prisma.
Use /// doc comments to give the LLM better context and mark fields that must never leave the server:
/// @vistal:description "A customer purchase order"
model Order {
id String @id @default(uuid())
status OrderStatus
/// @vistal:description "Order total in cents"
total Decimal
/// @vistal:sensitive
internal_notes String? // stripped at introspection — never in schemas, args, or results
}@vistal:sensitive is enforced before policy runs. The field doesn't exist as far as the LLM is concerned.
// Everything defaults to the tenant scope
vistal.policy("*", (ctx) => ({
read: { tenant_id: ctx.tenant.id },
write: { tenant_id: ctx.tenant.id },
delete: false,
}))
// Per-resource: override and extend
vistal.policy("order", (ctx) => ({
read: { tenant_id: ctx.tenant.id },
write: { tenant_id: ctx.tenant.id },
delete: false,
fields: { deny: ctx.user.role === "support" ? ["user_id"] : [] },
relations: { customer: ctx.user.role === "admin", items: true },
}))read, write, and delete accept:
| Value | Meaning |
|---|---|
true |
allow |
false |
deny — no tool generated |
{ field: value } |
read/delete: WHERE always AND-ed in · write: force-injected on INSERT, AND-ed on UPDATE/DELETE WHERE |
For each resource, vistal generates up to six tools based on what the policy allows:
| Tool | Operation |
|---|---|
query_{resource} |
findMany with filters, sort, pagination, relation includes |
get_{resource} |
findOne by id |
create_{resource} |
insert one row |
update_{resource} |
update by id |
delete_{resource} |
delete by id |
aggregate_{resource} |
count / sum / avg / min / max with optional groupBy |
delete: false → no delete_ tool. A required write field denied and not force-injected → create_ suppressed entirely, not silently broken. Fields with @default(...) are not required in create tools.
| Method | Use with |
|---|---|
vistal.tools.vercel(ctx) |
Vercel AI SDK — drops straight into generateText / streamText |
vistal.tools.anthropic(ctx) |
Anthropic Messages API |
vistal.tools.openai(ctx) |
OpenAI / any OpenAI-compatible API |
vistal.tools.gemini(ctx) |
Google Gemini |
vistal.tools.format(ctx, fn) |
Any other provider — pass your own formatter |
// OpenAI
const tools = await vistal.tools.openai(ctx)
await openai.responses.create({
model: "gpt-5",
tools,
input: "Show this customer's recent orders",
})
// Vercel AI SDK
const tools = await vistal.tools.vercel(ctx)
await generateText({ model, tools, maxSteps: 5, prompt })
// Anthropic
const tools = await vistal.tools.anthropic(ctx)
await anthropic.messages.create({ tools: tools.map(t => t.definition) })
const result = await tools.find(t => t.name === block.name)!.execute(block.input)
// Custom provider
const tools = await vistal.tools.format(ctx, (t) => ({ id: t.name, schema: t.parameters }))Tool errors are caught and returned as { error } so the agent can recover rather than abort.
new Vistal({
onQuery: ({ toolName, resource, durationMs, error }) => {
logger.info({ toolName, resource, durationMs })
if (error) logger.error({ toolName, error: error.message })
},
})| Property | Guarantee |
|---|---|
| Row filters | AND-ed server-side into every query — the LLM can send conflicting filters, they get overwritten |
| Write fields | write: { tenant_id } is injected into INSERT data and AND-ed into UPDATE/DELETE WHERE — no argument bypasses it |
| Tool suppression | false on any operation → no tool generated, nothing to call |
| Sensitive fields | Stripped at introspection, before policy runs — never in schemas, args, or results |
| Relation joins | belongsTo results enforce the related record's row filter post-fetch |
| Broken creates | If a required write field is denied and not force-injected, create_ is suppressed, not silently broken |
@vistal/prisma is the first adapter. Everything above it — policies, tool generation, the query IR — is ORM-agnostic. An adapter is two methods:
import type { VistalAdapter, SchemaMap, ResolvedQuery } from "@vistal/core"
class MyAdapter implements VistalAdapter {
async introspect(): Promise<SchemaMap> { ... }
async execute(query: ResolvedQuery): Promise<unknown> { ... }
}SchemaMap, ResolvedQuery, and FilterNode are all exported from /core.
examples/ecommerce/ — a full working demo with three users (admin, support, cross-tenant) issuing the same prompts against a live Postgres database. Includes a stress-test suite verifying tenant isolation, sensitive field exclusion, write policy enforcement, and role-based field denial.
MIT