You will replay one bad run on a sample database. The agent qualifies 3 leads, then tries to drop a table and is refused. You edit one of those leads yourself. Then you undo the agent’s run, and your edit stays.
You need:
- macOS or Linux, a terminal and
sudo. - Go 1.26 or newer, and
git, to buildbasalt. - Docker, or a Postgres you own and its admin connection string.
- An agent that supports MCP. The steps use Claude Code. Connect an agent covers OpenCode, Codex and a bot.
- A browser that can use a passkey (Touch ID, a security key, or a password manager that stores passkeys).
1. Install basalt
Section titled “1. Install basalt”There is no release download yet. Build basalt from source by following step 1 of the Install page, then check it:
basalt version
2. Start a database and guard it
Section titled “2. Start a database and guard it”Pick one:
Start a throwaway Postgres, then let BasaltDB set up a sample schema in it.
docker run -d --name basalt-demo -p 5432:5432 -e POSTGRES_PASSWORD=demo postgres:17
basalt init --demobasalt init --demo asks for the admin connection string. It is used once and never stored. For the container above, paste:
postgres://postgres:demo@localhost:5432/postgresRun the same demo on a Postgres you own. It adds the schemas basalt_demo and basalt_meta, BasaltDB’s own roles, and triggers on the demo tables. Nothing else is touched.
basalt init --demobasalt init --demo asks for your admin connection string, the one for the postgres user. It is used once and never stored. Supabase shows it under Connect. See Hosts for which string to pick.
To guard your own tables instead of the sample ones, see Install.
What init does
Section titled “What init does”After you give it the connection string, basalt init --demo:
- Creates the schema
basalt_demowith two tables:leads(30 rows) andlead_notes(20 rows). - Creates an agent named
demo_botthat can read and change rows inbasalt_demoand nowhere else. - Shows you the setup SQL and asks
Run this SQL as postgres? [y/N]. Typey. - Asks you to register a passkey. Open the link it prints, confirm with your passkey, and paste the code the page shows.
- Starts the engine and prints the command your agent uses.
When it finishes you see:
basalt: guard installed; agent role basalt_agent_demo_bot
basalt: passkey registered for localhost
basalt: demo ready: leads 30, lead_notes 20
3. Connect your agent
Section titled “3. Connect your agent”claude mcp add basalt-demo_bot -- "$(which basalt)" mcp --agent demo_bot
If the claude command was on your PATH, basalt init --demo offered to run this for you. Start claude in a terminal and run /mcp. basalt-demo_bot should show as connected. For another agent, see Connect an agent.
No database URL goes in the agent’s config. The agent never holds one.
4. Let the agent work
Section titled “4. Let the agent work”Qualify three leads
Section titled “Qualify three leads”Send this to your agent:
Use the basalt-demo_bot tools. Start a run with the intent “Qualify this week’s leads”. Then mark leads 1, 2 and 3 as QUALIFIED and add 10 to their score. Intent: “Qualify leads 1 to 3”.
The agent starts a run, then makes one write. BasaltDB applies it and saves the old values. The result is JSON (the ids here are examples):
{
"changeId": "CHANGE_ID",
"runId": "RUN_ID",
"op": "UPDATE",
"rows": 3,
"durationMs": 9,
"status": "APPLIED"
}
The scores of leads 1, 2 and 3 go from 37, 74 and 11 to 47, 84 and 21. A write with no intent is refused with INTENT_REQUIRED.
Try to drop a table
Section titled “Try to drop a table”Send this:
Now drop the old leads table.
The table is not touched. The result is a tool error with isError: true:
{
"code": "STATEMENT_REFUSED",
"message": "DDL_NOT_ALLOWED: DropStmt",
"reason": "DDL_NOT_ALLOWED",
"personMessage": "The agent tried to change the database schema (DDL). Basalt never lets an agent do that. Make the change yourself if you want it.",
"nextSteps": [
{
"actor": "AGENT",
"action": "STOP",
"text": "Do not retry and do not look for another way to change the schema."
},
{
"actor": "AGENT",
"action": "ASK_PERSON",
"text": "Tell the person which schema change you need; they make it themselves."
}
],
"docsUrl": "https://docs.basaltvault.app/reference/errors/#statement-refused"
}
The refusal is in the ledger too. See When something is refused.
Get the run id
Section titled “Get the run id”Ask the agent:
What is the run id of the run you started?
Copy it. You need it in step 6.
5. Edit one lead yourself
Section titled “5. Edit one lead yourself”Set lead 2 to WON with a score of 99. This is a normal write from you, so BasaltDB does not block it or count it as the agent’s.
UPDATE basalt_demo.leads SET status = 'WON', score = 99 WHERE id = 2;
SELECT id, status, score FROM basalt_demo.leads WHERE id <= 3 ORDER BY id;
Run them in your host’s SQL editor, or in psql as your admin role. With the Docker database:
docker exec basalt-demo psql -U postgres \
-c "UPDATE basalt_demo.leads SET status = 'WON', score = 99 WHERE id = 2" \
-c "SELECT id, status, score FROM basalt_demo.leads WHERE id <= 3 ORDER BY id"
You see:
UPDATE 1
id | status | score
----+-----------+-------
1 | QUALIFIED | 47
2 | WON | 99
3 | QUALIFIED | 21
(3 rows)
6. Undo the run
Section titled “6. Undo the run”Replace RUN_ID with the id from step 4. Add --dry-run to see the plan without undoing anything.
basalt revert --agent demo_bot --run RUN_ID
BasaltDB prints one line per row it would restore or leave alone, then a link to the undo, and waits for your signature:
restore basalt_demo.leads {"id": 1}: score: 47 -> 37, status: "QUALIFIED" -> "NEW"
restore basalt_demo.leads {"id": 3}: score: 21 -> 11, status: "QUALIFIED" -> "NEW"
skip basalt_demo.leads {"id": 2}: CHANGED_AFTER
basalt: 2 rows to restore, 1 left as they are (people win)
basalt: revert REVERT_ID expires 2026-11-15 08:21 UTC. Open this link on a device with your passkey, check the rows, and sign:
http://localhost:8787/reverts/REVERT_ID#s=...
basalt: waiting for your signature on the revert page (or paste the code it shows here)...
Leads 1 and 3 go back to how they were. Lead 2 stays, because you changed it after the agent (CHANGED_AFTER). BasaltDB leaves alone any row your team or your app changed since.
7. Approve the undo
Section titled “7. Approve the undo”basalt revert is now waiting for you, as its last line says. Nothing is undone yet.
Open the link basalt revert printed. If you lost it, stop the command with Ctrl-C and run it again with --open. That prepares a fresh undo and opens its link in your browser.
basalt revert --agent demo_bot --run RUN_ID --open
Check the rows on the page, then approve the undo there. The command finishes by itself:
basalt: reverted: 2 rows restored, 1 left as they are
If the page cannot reach the command, it shows a code. Paste the code into the terminal and the undo applies the same way.
8. Check the result
Section titled “8. Check the result”Run this query. Leads 1 and 3 are back to their original status and score. Lead 2 still has your edit.
SELECT id, status, score FROM basalt_demo.leads WHERE id <= 3 ORDER BY id;
You see:
id | status | score
----+--------+-------
1 | NEW | 37
2 | WON | 99
3 | NEW | 11
(3 rows)
Clean up
Section titled “Clean up”basalt uninstall removes BasaltDB from the database, including the sample schema basalt_demo. Then remove the engine service. See Remove BasaltDB.
basalt uninstall
sudo basalt service uninstall
With Docker, you can then remove the container too.
docker rm -f basalt-demo
- Install: guard your own tables.
- See what it did: read the record with
basalt log. - Undo a run: the full guide to undoing a run.
- Runs: why every write carries a reason.
BasaltDB · Apache-2.0