🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@vistal/core

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@vistal/core

ORM + Access Control Layer for AI Agents

latest
Source
npmnpm
Version
0.3.0
Version published
Weekly downloads
1
-87.5%
Maintainers
1
Weekly downloads
 
Created
Source

@vistal/core

The authorization layer for AI agents — zero-dependency core.

npm license TypeScript

Reads an ORM schema, generates typed LLM tools, and enforces row-level security and field-level access control server-side on every query — in code, not prompts. Adapter-agnostic: works with any ORM or database through a two-method interface.

Most users should install @vistal/prisma (Prisma / PostgreSQL / MySQL / SQLite) or @vistal/clickhouse (ClickHouse), which wrap this package with a ready-made adapter and schema introspection. Use @vistal/core directly only if you're building a custom adapter.

Installation

npm install @vistal/core

What this package exports

ExportPurpose
VistalMain class — instantiate with an adapter, register policies, get tools
formats.anthropic / openai / geminiTool formatters — convert provider-neutral tools to provider-specific shapes
PolicyViolationError, ValidationErrorError types thrown by the policy engine
serializeResultSerializes Decimal, Date, BigInt in query results
buildResultSchemaJSON Schema for the result shape of a ResolvedQuery
Types: VistalAdapter, SchemaMap, ResolvedQuery, FilterNode, PolicyFn, PolicyResult, View, ViewResult, …All types needed to build a custom adapter

Building a custom adapter

An adapter is two methods: introspect() returns a SchemaMap describing your resources; execute() runs a ResolvedQuery against your database.

import type { VistalAdapter, SchemaMap, ResolvedQuery } from "@vistal/core"

class MyAdapter implements VistalAdapter {
  async introspect(): Promise<SchemaMap> {
    return {
      resources: {
        order: {
          name: "order",
          tableName: "Order",
          fields: {
            id:        { name: "id",        type: "uuid",   isId: true,  isNullable: false },
            tenant_id: { name: "tenant_id", type: "string", isId: false, isNullable: false },
            total:     { name: "total",     type: "number", isId: false, isNullable: false },
            status:    { name: "status",    type: "enum",   isId: false, isNullable: false, enumValues: ["pending", "shipped", "delivered"] },
          },
          relations: {},
        },
      },
    }
  }

  async execute(query: ResolvedQuery): Promise<unknown> {
    // query.resource  — resource name, e.g. "order"
    // query.operation — "findMany" | "findOne" | "create" | "update" | "delete" | "aggregate"
    // query.filters   — row filters AND-ed from the policy + the model's arguments
    // query.data      — write payload (create/update) with forced fields injected
    // query.include   — relation names to eager-load
    // query.sort / query.limit / query.offset
    // query.aggregations / query.groupBy
    // ... translate this into your ORM/DB call
  }
}

Then pass your adapter to Vistal:

import { Vistal } from "@vistal/core"

const vistal = new Vistal({
  adapter: new MyAdapter(),
  defaultPolicy: "deny-all",
})

Adapters may also implement an optional third method to power live views with native change notifications instead of polling:

class MyAdapter implements VistalAdapter {
  // ...
  subscribe(query: ResolvedQuery, onChange: () => void): () => void {
    // watch the underlying table(s); call onChange() when data may have changed.
    // The view then re-executes through the policy pipeline and diffs — the
    // notification never carries data, so it can never bypass policy.
    // Return an unsubscribe function.
  }
}

Policies

Register policies per resource. Each policy is a function that receives a context object and returns what is allowed:

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, AND-ed on UPDATE WHERE
  delete: false,                           // delete_order tool never generated
  fields:    { deny: ctx.user.role === "support" ? ["internal_notes"] : [] },
  relations: { items: true, customer: ctx.user.role === "admin" },
}))

// "*" is a wildcard fallback for resources without an explicit policy()
vistal.policy("*", (ctx) => ({
  read:   { tenant_id: ctx.tenant.id },
  write:  false,
  delete: false,
}))

read, write, and delete accept:

ValueMeaning
trueallow
falsedeny — no tool generated for this operation
{ field: value }row filter (read/delete) or force-injected field (write)

Generated tools

For each resource, vistal generates up to six tools depending on policy:

ToolOperation
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 generated. A required write field that is denied and not force-injected → create_ suppressed entirely.

Getting tools for your LLM provider

// Vercel AI SDK (requires `ai` peer dep)
const tools = await vistal.tools.vercel(ctx)
await generateText({ model, tools, maxSteps: 5, prompt })

// Anthropic
const tools = await vistal.tools.anthropic(ctx)
// tools[i].definition → pass to the API
// tools[i].execute(args) → dispatch on tool call

// OpenAI
const tools = await vistal.tools.openai(ctx)

// Gemini
const tools = await vistal.tools.gemini(ctx)

// Custom formatter
const tools = await vistal.tools.format(ctx, (t) => ({
  id: t.name,
  schema: t.parameters,
}))

Live views

Capture any read tool call as a re-executable, subscribable handle — e.g. to drive a live chart from a query the agent built, without the LLM in the loop:

const view = await vistal.view<Order>("query_order", toolCall.args, ctx)

view.resultSchema                       // JSON Schema of { data, hasMore, nextCursor? }
const { data } = await view.execute()   // data: Order[] — policies re-evaluated per call

const sub = view.subscribe(({ data }) => chart.update(data), {
  intervalMs: 5000,   // poll interval (default 5000)
  emitInitial: true,  // emit the first result immediately (default)
  onError: (e) => log.warn(e),  // polling continues after errors
})
sub.stop()

Accepts per-resource (query_x / get_x / aggregate_x) and consolidated (query + { resource }) calls; writes and meta tools throw ValidationError at creation, as do invalid args or a denied policy. Subscriptions poll + diff (emit only on change, never overlapping); when the adapter implements the optional subscribe(query, onChange) (see above), native change notifications replace the timer. Results are serialized like tool results, and onQuery events from views carry source: "view". The TS generic is developer-asserted; resultSchema is the runtime source of truth, derived from the introspected schema and policy-allowed fields at view creation.

Scale & lifecycle. Subscribers on the same View share one polling loop (late subscribers are served from cache); errors back off exponentially and reset on success; jitter (0–1) spreads polls across a fleet; VistalConfig.maxConcurrentViewQueries (default 16) caps simultaneous view executions per instance. diffKey: "id" adds row-level changes to each emission.

Persistence & governance. view.toJSON(){ vistal: "view", v: 1, toolName, args } — no ctx, by design; rehydrate with vistal.viewFromJSON(json, ctx) under a freshly resolved context. vistal.registerView(name, { toolName, args }) / openView(name, ctx) / listViews() maintain a governed catalog of allowed live queries.

Composition. compose([viewA, viewB], (a, b) => ...) runs a pure app-authored transform over multiple views and re-emits when the output changes. deriveView(view, { groupBy, aggregations, sort?, limit? }) applies a declarative, schema-validated reshape — data-only, so the spec can safely come from an agent — and derives its own resultSchema.

Codegen. generateViewTypes(view.resultSchema, "Order") emits OrderRow / OrderResult TypeScript interfaces from the runtime schema.

Type-safe resource names

Use InferResources to derive resource names from an existing typed client (e.g. Prisma):

import { Vistal, InferResources } from "@vistal/core"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

const vistal = new Vistal<DefaultContext, InferResources<typeof prisma>>({
  adapter: myAdapter,
  defaultPolicy: "deny-all",
})

// policy() and getTools() autocomplete and type-check resource names
vistal.policy("order", ...)

Observability

new Vistal({
  adapter,
  onQuery: ({ toolName, resource, operation, durationMs, error }) => {
    logger.info({ toolName, resource, durationMs })
    if (error) logger.error({ toolName, error: error.message })
  },
})

Available adapters

PackageDatabase
@vistal/prismaPostgreSQL, MySQL, SQLite (via Prisma 5+)
@vistal/clickhouseClickHouse

License

MIT

Keywords

ai agent

FAQs

Package last updated on 10 Jun 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts