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/serenedbWhy 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.
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.
- ✓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 - ✓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 - ✕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 - ✕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 - ✕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 - ✓Order status breakdownneeds USAGE on orders
SELECT 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
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.
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.
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.
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.
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.
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.
Beyond GRANT
four settingsVALID 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';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-256List 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;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_sqlAn 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.
docker run -d --name serenedb -p 7890:7890 \ -e POSTGRES_PASSWORD=secret serenedb/serenedb
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'));
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;
CREATE ROLE support_agent LOGIN PASSWORD 'change-me'; GRANT SELECT (id, product, body, emb) ON tickets TO support_agent;
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
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.
RAG database →SELECT id, body FROM tickets_idx WHERE body @@ 'password' ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3] LIMIT 3;
| id | body |
|---|---|
| 2 | Password reset email never arrives |
| 3 | Cannot log in after resetting my password |
$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.
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;
| status | count |
|---|---|
| paid | 2 |
| refunded | 1 |
| shipped | 1 |
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.
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)"
| check_sql of | answer |
|---|---|
| the search tool's SELECT | Valid. The plan: … IRESEARCH_SCAN tickets_idx |
| SELECT customer_email … | Invalid: … permission denied for table tickets |
| DELETE … WHERE id = 4 | Invalid: … permission denied for table tickets |
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 with SereneDB
Give each agent a login role, grant it the columns it needs, and write its tools as parameterized SQL.