Skip to main content
By default, sqliteStore / d1Store write agent sessions and messages to their own tables invisible to @vertz/server. Good for single-tenant apps, wrong for anything with users and tenants — Session.list({ where }) can’t be called from app code and rules.* (authenticated, tenant-scoped, row-level where) never fires on agent reads. The entity bridge fixes this: one factory call turns the store’s tables into first-class Vertz entities registered with createServer, so app-side reads flow through the CRUD pipeline with full RLS.
  • Same tables, same writes — sqliteStore/d1Store are unchanged on the hot path.
  • Reads from app code (/api/agent-session routes, ctx.entities['agent-session'].list()) enforce access rules.
  • Tenant auto-detection picks up tenantId on the session row; extended schemas with a .tenant() relation inherit the chain.

Setup

Three pieces: define tables with the exported column packs, call the factory, register the entities.

Default access rules

defineAgentEntities() applies user-scoped row-level rules on every op so the happy path is safe. ctx.userId / ctx.tenantId are injected by a before.create hook — callers never need to pass them explicitly, and if they do, ctx.userId wins (prevents impersonation).
OpSessionMessage
listrules.where({ userId: rules.user.id })rules.where({ userId: rules.user.id })
getrules.where({ userId: rules.user.id })rules.where({ userId: rules.user.id })
createrules.authenticated() (+ hook)denied (store writes)
updaterules.where({ userId: rules.user.id })denied
deleterules.where({ userId: rules.user.id })denied
Override with the factory options:

Reading agent data

Flow from the client goes through the auto-generated route:
From server-side code inside a route or service that has an EntityContext:
If you want dot-access, override the names (lowercase, no hyphen) at factory time:
Do not use db.agentSessions.list(...) from handler code and expect RLS — that path is the raw DatabaseClient delegate and bypasses the entity pipeline. It’s fine for internal/trusted code (migrations, admin tasks) but user-facing queries must go through the routes or ctx.entities.*.

Extending the schema

Add your own columns and relations by spreading the column packs and building your own d.table() — no special helper:

Tenant scoping

defineAgentEntities opts each entity into tenant scoping automatically when the tenantId column (from the pack) or a ref.one() to a .tenant() table is present. Priority: relation wins over column. If you add a .tenant() relation on your extended session, drop tenantId from the spread to avoid a dead column:

Typed state / toolCalls (opt-in JSONB)

The packs default to d.text() for state and toolCalls — a JSON string, byte-compatible with the legacy sqliteStore / d1Store DDL. Pass { useJsonb: true } to opt each pack into d.jsonb<T>(): typed reads on the entity row, plus native JSONB emission on Postgres (indexable, typed operators). On SQLite the on-disk shape is still TEXT; the driver auto-parses jsonb columns on read.
The AgentStore path (sqliteStore / d1Store) is orthogonal — it keeps its own JSON.stringify / JSON.parse wrapping and writes raw SQL. On SQLite the column emission is still TEXT, so stores and entities share the same rows without coordination.

Migrating an existing database

Fresh installs get the correct column type from the opt-in. Existing databases need a one-off migration only if you want the on-disk type to change (Postgres). On SQLite the on-disk type is TEXT in both modes, so no DDL change is required. SQLite / D1: no migration needed. d.jsonb<T>() emits the same TEXT storage — the only change is type-side (the driver parses jsonb columns on read, so entity callers see parsed objects; the AgentStore contract stays AgentSession.state: string). If you want to audit stored rows for malformed JSON before opting in, run SELECT id FROM agent_sessions WHERE json_valid(state) = 0. Postgres: the stores (sqliteStore / d1Store) are SQLite-only — pure-entity usage is the supported path on Postgres. Before running the ALTER, audit for rows that can’t cast to JSONB:
ALTER ... TYPE JSONB USING ::JSONB scans every row and aborts on the first value that isn’t valid JSON. If the table has a non-JSON legacy value (e.g. an opaque token stored via a pre-bridge writer), the ALTER rolls back and the schema stays TEXT.

Upgrade migration

Existing databases from earlier @vertz/agents versions must add two columns to agent_messages. A migration SQL file ships with the package at packages/agents/migrations/001-add-rls-columns.sql:
Fresh installs get the columns automatically — the stores’ built-in DDL was updated.

What the bridge does not cover

  • Entity hooks on agent-loop writes. before.create / after.create on the Message entity do not fire for messages inserted by run() — writes go through the AgentStore, not the entity pipeline. Reads and app-side writes via entity handlers do fire hooks normally. Tracked as #2957 (registration-time rejection of hooks on factory-produced entities).
  • RLS on in-loop reads. store.loadSession() inside run() bypasses entity rules — identity is checked by run() directly against session.userId / session.tenantId. Same ownership story as before the bridge; app-side reads get the full RLS.

See also