Read-only role.
Safe, until the agent needs to fix what it just found and you're pasting SQL by hand.
-- a seatbelt for postgres
Tablebelt is a Postgres proxy you run in your own infra. Point the agent's DATABASE_URL at it and ordinary SQL passes straight through. DROP, TRUNCATE, ALTER ... DROP and unscoped writes are held until you approve them. Approving takes a snapshot first, so one command puts the tables back.
DROP TABLE
users;
DROP TABLE
NOTICE: tablebelt: DROP TABLE public.users is held for approval as hold_0001. Waiting up to 60s. Approve with: tablebelt approve hold_0001
DROP TABLE users, held, approved, snapshotted and restored. You play the approver.-- the problem
Then it decided the users table looked like leftover test data. It's the same cause every time. The agent had real credentials, and the only guardrail was text in a prompt.
Safe, until the agent needs to fix what it just found and you're pasting SQL by hand.
A suggestion. The model can ignore it the moment a cleanup looks obvious.
A restore you've never run, and every write since midnight gone.
This is already happening in public. Replit's agent deleted a company's production database in July 2025, and a Cursor agent wiped a startup's database in April 2026.
-- how it works
Install one Go binary on your box and run tablebelt init. It prints a DATABASE_URL for the agent. Your app keeps its real Postgres URL, so Tablebelt is never between your users and the database.
Tablebelt parses every statement with the real Postgres parser. Ordinary queries pass straight through. A held statement waits, and the agent sees a NOTICE naming the hold. SQL it can't parse doesn't run.
On Free, run tablebelt approve or tablebelt deny in your terminal. On Pro and Team, approve from the dashboard, Slack or email, with audit history. If nobody decides within 60 seconds, the agent gets error TB001 and the hold stays open. Approve it, and the agent's identical retry runs once.
Approval snapshots the affected tables into a tablebelt schema in your own Postgres before the statement runs. If the snapshot fails, the statement doesn't run. tablebelt restore snap_0001 puts those tables back as they were at snapshot time. It isn't point in time recovery: writes after the snapshot are lost, and the restore tells you so.
Give your agent this, and only this:
DATABASE_URL=postgres://agent:<password>@127.0.0.1:5439/app?sslmode=require
Your app keeps its real connection string. The agent never sees it.
-- what it holds
Tablebelt walks the parse tree, not a list of string prefixes. If it can't parse a statement, it doesn't run it.
| statement | what happens |
|---|---|
DROP TABLE, DROP SCHEMA |
Held. The tables are snapshotted before it runs. |
DROP DATABASE |
Held. No snapshot is possible, so approving needs an explicit no-snapshot acknowledgement. A fresh install denies it by default. |
TRUNCATE |
Held. With CASCADE, the tables that reference it are snapshotted too. |
ALTER ... DROP |
Held. Columns and constraints a migration file can't bring back. |
UPDATE, DELETE with no real WHERE |
Held. WHERE true and WHERE 1 = 1 count as no WHERE. Scoped writes go through. |
DISABLE ROW LEVEL SECURITY |
Held. Nothing is deleted, but every row becomes readable. |
| SQL it can't parse | Denied. Production isn't the place for a best guess. |
UPDATE, DELETE or MERGE that would touch most of a table |
Held when Postgres estimates it touches more than half the table or more than 10,000 rows. Set by rules.large_write. |
| (8 rows) | |
The full rule list, including custom rules and Team approval rules, is in the rules docs.
-- install
Linux and macOS, amd64 and arm64. The script checks the download's checksum and never calls sudo. Homebrew and Docker are coming soon.
Those three commands start one database. tablebelt add blog adds another to the same proxy and prints its DATABASE_URL. The database name in the URL chooses it. Multiple databases.
curl -fsSL https://tablebelt.com/install.sh | sh
tablebelt init
tablebelt run
-- your box
Snapshots live in your own Postgres. We never see database credentials or rows. On Pro and Team, the proxy sends the held statement and metadata (table names, estimated rows, client name) over outbound HTTPS. Full statement text is sent by default, and you can opt in to redact it to a fingerprint and table names.
It sits only in the agent's path. If the proxy is down, the agent is blocked and your app keeps its direct connection.
A Go binary you can run under systemd, or just run.
-- works with
Claude Code, Codex, Cursor and any MCP client. psql and ORMs too, since it speaks the Postgres wire protocol. It works with hosts that give you a normal connection string: DigitalOcean, RDS, Neon, Railway, Supabase, Render, or your own server. Poolers, IAM auth and forced TLS can break a wire proxy, so use a direct connection if yours does. Host notes
-- pricing
$0 unlimited local databases
tablebelt approve$19 per database per month
Paid plans open shortly.
Start free$79 per month, up to 10 databases
Paid plans open shortly.
Start freeAmounts are in USD. You only need an API key for hosted approvals on Pro or Team.
-- faq
No. Database credentials and row data stay in your infra, and snapshots stay in your own Postgres. On Pro and Team the proxy makes outbound HTTPS calls with the held statement and metadata (table names, estimated rows, client name), then polls for the decision. It never opens an inbound port for us. Full statement text is sent by default. You can opt in to redact the statement and send only a fingerprint and table names. Free makes no outbound calls.
It's a Postgres wire protocol proxy you run. An MCP server, psql or an ORM all connect to it like any Postgres. Point the agent at Tablebelt. Your app keeps the real URL.
One proxy and one port cover them. tablebelt init writes a single database. tablebelt add blog adds another one to the same tablebelt.yaml and prints that database's DATABASE_URL. The client picks the database with the startup parameter, which is the database name in the URL. Each database has its own agent credential. Hold, snapshots and rules inherit from the global sections, and a field on that entry overrides them for that database. The upstream DSN does not inherit. Free covers unlimited local databases and makes no outbound calls. Pro is $19 per database per month. Team is $79 per month for up to 10 databases. On a paid plan, a database that is not covered uses local approvals. The client sees: database blog is not on your plan; add it in the dashboard. Multiple databases.
It parses, with the real Postgres parser. SQL it can't parse doesn't run.
The snapshotted tables, as they were at snapshot time. Later writes to those tables are lost. It isn't whole database point in time recovery, and it doesn't undo a cascade it didn't snapshot. DROP DATABASE is held, but v1 can't snapshot a whole database, so approving it needs an explicit no-snapshot acknowledgement, and a fresh install denies it by default.
The agent's connection waits up to 60 seconds with a NOTICE naming the hold. Approve in time and it runs on the same connection. Otherwise the agent gets error TB001, the hold stays open, and the identical statement runs once when it's retried after approval.
If the proxy is down, the agent is blocked and your app keeps running on its direct connection. On Pro and Team, if the dashboard is unreachable, the proxy falls back to local CLI approval with tablebelt approve. Held queries keep waiting until they're approved, denied or timed out, and those decisions sync to the audit log when the cloud comes back. Queries that aren't held are unaffected.
Queries that aren't held pass straight through. A held statement waits until it's approved, denied or timed out.
Yes, if it can read the raw database URL. Give it only the Tablebelt URL, keep the real one off that machine, and use a Postgres role it can only reach through Tablebelt. Block .env reads with hooks if your agent supports them.
A read-only role means the agent can't fix anything. PITR rolls the whole database back. Tablebelt holds one statement, snapshots what it touches, and lets everything else carry on.
Postgres only. If that's a deal breaker, pick "Other" as your host on the form.
The proxy and CLI are source-available under FSL-1.1-ALv2, which converts to Apache 2.0 two years after each release. Hosted approvals and audit history are a paid service.
Enter your email. We'll email your install command. Free is the self-hosted proxy, CLI approvals and local snapshots. You only need an API key for hosted approvals on Pro or Team.
-- why i built it
I'm Kyle Redelinghuys. I write Go, Vue and Postgres, run it all on DigitalOcean, and have Claude Code, Cursor or Codex open most days. My most-read post is on when --dangerously-skip-permissions is fine, and I built Vouch because an agent saying "done" isn't proof. Tablebelt comes from the same view: a CLAUDE.md rule is a suggestion, and the control that holds is in the call path. If your stack looks different, tell me on the form.
Email is enough. I'll send your install command.