SereneDB

Database for AI agents: one login role each, scoped with GRANT

SereneDB gives each AI agent its own login role over the PostgreSQL wire protocol. GRANT decides which tables, columns and remote servers its tool calls may read, privileges are checked on every statement, and one query matches the exact term with BM25, ranks by vector distance and counts the result.

docker run -d -p 7890:7890 -e POSTGRES_PASSWORD=secret serenedb/serenedb
agent → one sql endpointgranteddenied
An animation of an agent's tool calls passing a role gate on their way into SereneDB. search_tickets('resets') runs SELECT product, count(*) FROM tickets_idx WHERE body @@ $1 GROUP BY product as support_agent, which holds SELECT on id, product, body and emb; BM25 stems resets to reset and finds tickets 2 and 3, so the answer is auth with 2 tickets. DELETE FROM tickets WHERE id = 4 stops at the gate with permission denied for table tickets, because the role holds no DELETE, and the row stays. SELECT status, count(*) FROM orders.public.orders GROUP BY status goes through the orders foreign server, on which the role holds USAGE, to PostgreSQL and returns paid 2, refunded 1 and shipped 1.
Guardrails in the database

Why an AI agent can't delete what it was never granted

An application sends a few queries its developers reviewed; an agent sends statements nobody reviewed, and a coding agent has deleted a production database that way. SereneDB checks privileges on every statement, so the role an agent logs in as decides which of its calls run. Toggle the grants of support_agent: every result below ran on SereneDB 26.11.2 against the four tickets of the walkthrough.

role support_agent
select on tickets (columns)
other privileges
CREATE ROLE support_agent LOGIN PASSWORD 'change-me';
GRANT SELECT (id, product, body, emb)
    ON tickets TO support_agent;
GRANT USAGE ON FOREIGN SERVER orders
    TO support_agent;

To limit rows, grant a view instead of the table: CREATE VIEW auth_tickets AS SELECT id, body, emb FROM tickets_idx WHERE product = 'auth' with GRANT SELECT ON auth_tickets. Full-text and vector queries still run through the view.

the agent's tool calls3 of 6 allowed
  1. ✓How many tickets mention resets, per product?needs SELECT (product, body)SELECT product, count(*) FROM tickets_idx WHERE body @@ 'resets' GROUP BY productgranted: auth | 2 · stemming matches reset and resettingwithout the grant: ERROR: permission denied for table tickets
  2. ✓Tickets like “password reset not working”needs SELECT (id, emb)SELECT id FROM tickets_idx ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3] LIMIT 3granted: 2, 3, 1 · nearest firstwithout the grant: ERROR: permission denied for table tickets
  3. ✕Email the customer on ticket 2needs SELECT (customer_email, id)SELECT customer_email FROM tickets WHERE id = 2granted: b@example.comwithout the grant: ERROR: permission denied for table tickets · no SELECT on customer_email
  4. ✕Move ticket 3 to billingneeds UPDATE + SELECT (id)UPDATE tickets SET product = 'billing' WHERE id = 3granted: UPDATE 1 · the row changedwithout the grant: ERROR: permission denied for table tickets · no UPDATE
  5. ✕Clean up ticket 4needs DELETE + SELECT (id)DELETE FROM tickets WHERE id = 4granted: DELETE 1 · the row is gonewithout the grant: ERROR: permission denied for table tickets · no DELETE
  6. ✓Order status breakdownneeds USAGE on ordersSELECT status, count(*) FROM orders.public.orders GROUP BY statusgranted: paid 2 · refunded 1 · shipped 1, from PostgreSQLwithout the grant: ERROR: permission denied for foreign server orders

Architecture

A driver, a role, one engine over every catalog

An agent reaches SereneDB through a PostgreSQL driver, as a role that decides which tables, columns and servers each statement may touch, and one engine runs search and SQL over every catalog the role sees. Hover a stage, or a catalog inside the engine.

01 · agent
Tool call
fixed sql %s parameters
02 · driver
A PostgreSQL driver
wire protocol port 7890
03 · role
support_agent
select (columns) usage on servers
04 · serenedb
One engine
tickets_idxbm25 · ivf
orders.public.*postgres_fdw
logs_v → parquetview
privileges checked on every statement
05 · context
The answer, not the evidence
counts · top-k rows
> stage 01 · tool

The model picks the term; the statement is fixed

A tool is a parameterized statement: the model fills %s, never the SQL text. psycopg sends a Python list as DOUBLE[], so cast a vector to the column's FLOAT[N]. File readers such as read_csv answer to the server-wide enable_external_access setting, not to GRANT, so keep file paths out of the model's hands too.

python
import psycopg
conn = psycopg.connect(
"host=localhost port=7890 dbname=postgres user=support_agent password=change-me"
)
def similar_tickets(vector):
return conn.execute(
"SELECT id FROM tickets_idx"
" ORDER BY emb <-> %s::FLOAT[3] LIMIT 3",
(vector,),
).fetchall()
similar_tickets([1.0, 0.0, 0.0]) # [(2,), (3,), (1,)]
> stage 02 · driver

Any PostgreSQL driver

SereneDB speaks the PostgreSQL wire protocol on port 7890, so psycopg, node-postgres, JDBC, Npgsql, libpqxx, tokio-postgres and RPostgres connect as they do to PostgreSQL (clients). LangChain agents can use langchain-serenedb as a vector store and retriever.

shell
docker run -d --name serenedb -p 7890:7890 \
-e POSTGRES_PASSWORD=secret serenedb/serenedb
psql "host=localhost port=7890 dbname=postgres user=support_agent"
> stage 03 · role

A login role per agent

Give each agent its own LOGIN role and grant it the columns it reads. An HBA rule pins the role to the agent host's network, and VALID UNTIL ends its password. Avoid one privileged connection that runs SET ROLE per agent: SQL sent through it can run RESET ROLE and act as the connection's own role again. An agent's own SET ROLE postgres fails.

sql
CREATE ROLE support_agent LOGIN PASSWORD 'change-me';
GRANT SELECT (id, product, body, emb) ON tickets TO support_agent;
SET hba = '
host all support_agent 10.0.4.0/24 scram-sha-256
host all support_agent 0.0.0.0/0 reject
host all support_agent ::0/0 reject
local all all scram-sha-256
host all all 0.0.0.0/0 scram-sha-256
host all all ::0/0 scram-sha-256';
> stage 04 · one namespace

Every store is one SQL namespace

Tickets in SereneDB, orders in PostgreSQL, logs in Parquet files: as separate stores, each would be one more tool, credential and SQL dialect for the model. In SereneDB they are catalogs of one engine. USAGE on a foreign server decides which roles reach it, and a view over read_parquet takes a GRANT like a table.

sql · hover a line
CREATE INDEX tickets_idx ON tickets
USING inverted (id, product, body en, emb ivf (metric = 'l2'));
CREATE SERVER orders FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'pg.internal', port '5432', database 'shop',
user 'reader', password 'secret');
CREATE VIEW logs_v AS
SELECT * FROM read_parquet('/var/lib/serenedb/logs/*.parquet');
> stage 05 · context window

Return the answer, not the evidence

Every row a tool returns lands in the model's context window. BM25 for the exact term, vector distance for the meaning and GROUP BY for the count return the answer instead of every matching ticket. Hybrid search fuses the two rankings when one order is needed.

sql
SELECT id, body FROM tickets_idx
WHERE product @@ 'auth'
ORDER BY emb <-> [0.95, 0.05, 0.0]::FLOAT[3]
LIMIT 5;
-- 2 | Password reset email never arrives
-- 3 | Cannot log in after resetting my password
SELECT product, count(*) FROM tickets_idx
WHERE body @@ 'password'
GROUP BY product;
-- auth | 2
A diagram in five stages. An agent's tool call is a fixed SQL statement with %s parameters. A PostgreSQL driver sends it over the wire protocol to port 7890. The login role support_agent, which holds SELECT on four columns of tickets and USAGE on the orders server, decides what the statement may touch. One SereneDB engine answers it from the inverted index tickets_idx with BM25 and IVF, from PostgreSQL through the postgres_fdw server orders, or from Parquet files through the view logs_v. The result that goes back into the model's context is a count or the top rows, not every matching row.

Beyond GRANT

four settings
expiry
Passwords that end

VALID UNTIL ends a role's password at a set time. ALTER ROLE … NOLOGIN refuses its next login at once with "role … is not permitted to log in".

ALTER ROLE support_agent VALID UNTIL '2027-01-01';
network
Pinned to the agent host

An HBA rule per role. From any other address the login fails with no pg_hba.conf entry.

host all support_agent 10.0.4.0/24 scram-sha-256
files
File readers off

List the folders your views read in allowed_directories, then turn external access off: read_csv('/etc/hostname') fails and logs_v still answers. Superuser only, and it can't be turned back on while the server runs.

SET GLOBAL enable_external_access = false;
mcp
Check SQL before it runs

An HTTP listener with ?api=mcp serves the running version's docs and check_sql, which plans one statement as the authenticated role without running it.

?api=mcp → check_sql

Build it

An agent that searches tickets and can't overreach

One container, one table, one role: five steps to an agent that searches tickets and cannot read customer_email or delete a row. Every statement and result below ran on SereneDB 26.11.2 on 2026-11-03.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 \
  -e POSTGRES_PASSWORD=secret serenedb/serenedb
POSTGRES_PASSWORD sets the superuser's password. Run steps 2 to 4 as postgres: psql -h localhost -p 7890 -U postgres.
step 02 / 05 · sql
CREATE TEXT SEARCH DICTIONARY en AS
    split_text(case := 'lower') | stem_words('en_US.UTF-8')
    WITH (frequency, position);

CREATE TABLE tickets (id INTEGER PRIMARY KEY, product VARCHAR,
    body VARCHAR, customer_email VARCHAR, emb FLOAT[3]);

CREATE INDEX tickets_idx ON tickets
    USING inverted (id, product, body en, emb ivf (metric = 'l2'));
step 03 / 05 · sql
INSERT INTO tickets VALUES
(1, 'billing', 'Invoice shows a duplicate charge', 'a@example.com', [0.0, 1.0, 0.0]::FLOAT[3]),
(2, 'auth', 'Password reset email never arrives', 'b@example.com', [1.0, 0.0, 0.0]::FLOAT[3]),
(3, 'auth', 'Cannot log in after resetting my password', 'c@example.com', [0.9, 0.1, 0.0]::FLOAT[3]),
(4, 'network', 'Connection drops every few minutes', 'd@example.com', [0.0, 0.0, 1.0]::FLOAT[3]);

VACUUM (REFRESH_TABLE) tickets;
INSERT 0 4. Rows become searchable at the next automatic refresh, every 1000 ms by default, or at once after VACUUM (REFRESH_TABLE).
step 04 / 05 · sql
CREATE ROLE support_agent LOGIN PASSWORD 'change-me';

GRANT SELECT (id, product, body, emb) ON tickets TO support_agent;
information_schema.columns now lists id, product, body and emb for this role. INSERT, UPDATE, DELETE, TRUNCATE and a SELECT of customer_email fail with permission denied for table tickets.
step 05 / 05 · python
import psycopg

conn = psycopg.connect(
    "host=localhost port=7890 dbname=postgres user=support_agent password=change-me"
)

def search_tickets(term):
    return conn.execute(
        "SELECT product, count(*) FROM tickets_idx"
        " WHERE body @@ %s GROUP BY product",
        (term,),
    ).fetchall()

search_tickets("resets")  # [('auth', 2)]
conn.execute("DELETE FROM tickets WHERE id = 4")
# InsufficientPrivilege: permission denied for table tickets
Stemming matches reset and resetting, so both auth tickets count. The DELETE raises, and ticket 4 stays.

Use cases

What teams build on it

Support and triage agents

Tickets and release notes as indexed tables. BM25 catches the exact error text, vector distance the same failure in other words, and GROUP BY counts how many customers hit it, while the agent's role keeps customer emails out of its reach. Answers grounded in your documents are the RAG side of the same engine.

indexed columns
body enemb ivfproduct
RAG database →
example · search tool
password reset not working
SELECT id, body FROM tickets_idx
WHERE body @@ 'password'
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
LIMIT 3;
result · as support_agent
idbody
2Password reset email never arrives
3Cannot log in after resetting my password
In production the agent passes its question as $1, and ai_embed($1, 'all-minilm', 'local_ai')::FLOAT[384] embeds it inside the same statement, against a FLOAT[384] column.

Data agents over live sources

Text-to-SQL needs the schema, and information_schema lists only the columns the agent's role was granted. The same SQL reaches PostgreSQL and ClickHouse tables through CREATE SERVER and files through views, without a pipeline in between.

what the agent uses
information_schemaCREATE SERVERread_parquet
Zero-ETL search →
example · a remote source
CREATE SERVER orders FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host 'pg.internal', port '5432', database 'shop',
             user 'reader', password 'secret');

GRANT USAGE ON FOREIGN SERVER orders TO support_agent;

-- as support_agent
SELECT status, count(*) FROM orders.public.orders
GROUP BY status ORDER BY status;
result · from PostgreSQL 17
statuscount
paid2
refunded1
shipped1
A foreign server's credentials serve every role that holds USAGE on it, so give it a read-only remote user. Here reader may read orders.id and orders.status only, and SELECT card_number fails with permission denied for table orders.

Agents that write SereneDB SQL

SereneDB ships its documentation inside the server. An HTTP listener with ?api=mcp gives Claude Code or any MCP client search_docs, read_doc, list_docs, list_objects, describe_object and check_sql, which plans one statement as the authenticated role and never runs it. For MCP search over your own docs, see documentation search.

what the agent uses
MCPcheck_sqlsdb_docs
Documentation search →
example · mcp
serened ./data --listen 'postgres://127.0.0.1:7890,http://127.0.0.1:8080?api=mcp'

claude mcp add --transport http serenedb http://127.0.0.1:8080/_mcp \
  --header "Authorization: Basic $(printf 'support_agent:change-me' | base64)"
result · as support_agent
check_sql ofanswer
the search tool's SELECTValid. The plan: … IRESEARCH_SCAN tickets_idx
SELECT customer_email …Invalid: … permission denied for table tickets
DELETE … WHERE id = 4Invalid: … permission denied for table tickets
check_sql plans with EXPLAIN and never runs the statement. Without a valid password the endpoint answers 401.

FAQ

Frequently asked questions

Go to docs →

A database for AI agents is one whose clients are agents: statements arrive from tool calls, unreviewed and at machine pace, so it has to answer search and SQL questions in one call and enforce what each agent may touch. In SereneDB each agent connects over the PostgreSQL wire protocol as its own role, runs full-text, vector and hybrid search with SQL aggregates in one statement, and reads only what that role was granted.

Judge it by six needs: exact terms, meaning, counts, the other stores the agent reads, a scope per agent, and a schema the agent can discover. SereneDB runs BM25, vector distance and GROUP BY in one statement, queries PostgreSQL, ClickHouse and Parquet files as catalogs, checks each agent's role and column grants on every statement, and shows each role only its own columns in information_schema. The walkthrough above ran on SereneDB 26.11.2.

Connect the agent as a role that never received DELETE, UPDATE or TRUNCATE. SereneDB checks privileges on every statement, so DELETE FROM tickets fails with permission denied for table tickets, and DROP TABLE needs the table's owner. Don't hand the agent the superuser or a connection that switches roles with SET ROLE, and cut a role off with ALTER ROLE … NOLOGIN.

Create a LOGIN role and grant SELECT on the columns it needs, nothing else: GRANT SELECT (id, product, body, emb) ON tickets TO support_agent. INSERT, UPDATE and DELETE fail because they were never granted, and a column grant does not confer the rest of the table, so SELECT customer_email fails too.

Yes, with a view: CREATE VIEW auth_tickets AS SELECT id, body, emb FROM tickets_idx WHERE product = 'auth', then grant SELECT on the view only. A view reads its sources with its owner's privileges, so the agent sees tickets 2 and 3 and gets permission denied on the table, while full-text and vector queries still run through the view.

Through a tool that runs parameterized SQL over a PostgreSQL driver on port 7890: psycopg, node-postgres, JDBC, Npgsql and the others in the clients docs. LangChain agents can use langchain-serenedb as a vector store and retriever. Give every agent its own login role, never postgres.

Yes. An HTTP listener with ?api=mcp serves the running version's documentation and check_sql, which plans one statement as the authenticated role without running it, so a coding agent can check its SQL against your schema and grants. Queries themselves go over a PostgreSQL driver. For MCP search over your own documentation, see documentation search.

They need vector search, and usually more: an exact error code needs BM25 and a "how many" needs GROUP BY. In SereneDB one inverted index covers the text columns and the FLOAT[N] vector column, so one statement filters by term, orders by distance and counts. See vector search.

From information_schema, which lists only the columns its role was granted: for support_agent, id, product, body and emb, never customer_email. For the SQL dialect, sdb_docs.search() and the MCP tools return the documentation of the running version, over the agent's own connection.

> get started

Get started with SereneDB

Give each agent a login role, grant it the columns it needs, and write its tools as parameterized SQL.