Gate Database Writes From an AI Agent
A text-to-SQL agent's query is a guess. Gate database writes from an AI agent — INSERT, UPDATE, DELETE — behind human approval before they touch production.
The shape of the problem
Text-to-SQL agents are useful precisely because they turn "mark these five overdue invoices as written off" into a working query without anyone hand-writing SQL. That's also what makes them dangerous: the query the model generates is a guess at intent, and a guess that's slightly too broad — a missing WHERE clause, a join that fans out rows, a LIKE pattern that matches more than intended — turns into a write that touches thousands of rows instead of five.
Read-only queries (SELECT) don't need this. Writes do. The dividing line for where to put the gate is simple:
| Query type | Gate before executing? |
|---|---|
SELECT |
No — read access is not a side effect Impri needs to mediate |
INSERT / UPDATE |
Yes — the row didn't exist / didn't look like that before |
DELETE / TRUNCATE |
Yes, and treat as high-severity — see gating deletes specifically |
DDL (ALTER TABLE, etc.) |
Yes — schema changes are rarely something an agent should self-approve |
Wiring the gate with the Impri MCP server
If the agent runs inside Claude Code, Claude Desktop, or another MCP client, the cleanest integration point is the query-execution tool itself — not the SQL-generation step. The model can draft as many candidate queries as it wants; only the executor is gated.
Add the Impri MCP server to the agent's config:
{
"mcpServers": {
"impri": {
"command": "npx",
"args": ["@impri/mcp"],
"env": {
"IMPRI_API_KEY": "im_your_key_here",
"IMPRI_BASE_URL": "https://api.impri.dev"
}
}
}
}Then the agent's write-executor tool is described (in its own tool definition) as requiring an approved Impri action before it will run. The flow the agent follows, driven by MCP tool calls:
import { execute } from "./db";
interface WriteRequest {
sql: string;
params: unknown[];
affectedTable: string;
estimatedRowCount: number;
}
async function proposeWrite(req: WriteRequest): Promise<string> {
// impri_push_action is called by the agent's MCP client, not by app code —
// shown here as the equivalent REST call for clarity.
const res = await fetch("https://api.impri.dev/v1/actions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMPRI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "db.write",
title: `${req.sql.split(" ")[0]} on ${req.affectedTable} (~${req.estimatedRowCount} rows)`,
preview: {
format: "markdown",
body: `\`\`\`sql\n${req.sql}\n\`\`\`\n\nParams: \`${JSON.stringify(req.params)}\`\n\nEstimated rows affected: **${req.estimatedRowCount}**`,
},
idempotent: req.sql.trim().toUpperCase().startsWith("INSERT") ? false : undefined,
undo: "No automatic undo — write is applied directly to production.",
expires_in: 1800,
}),
});
const { id } = await res.json();
return id;
}
async function awaitAndExecute(actionId: string, req: WriteRequest) {
let status = "pending";
let decision;
while (status === "pending") {
await new Promise((r) => setTimeout(r, 5000));
const res = await fetch(`https://api.impri.dev/v1/actions/${actionId}`, {
headers: { Authorization: `Bearer ${process.env.IMPRI_API_KEY}` },
});
({ status, decision } = await res.json());
}
if (status !== "approved") {
console.log(`Write blocked: ${status}`);
return;
}
await execute(req.sql, req.params);
await fetch(`https://api.impri.dev/v1/actions/${actionId}/result`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMPRI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status: "executed" }),
});
}estimatedRowCount is worth computing before pushing the action — run the WHERE clause as a SELECT COUNT(*) first. A reviewer approving "update 5 rows" and a reviewer approving "update rows matching this filter, count unknown" are making very different decisions; give them the number.
Why the SQL text belongs in the preview, not just a summary
It's tempting to summarize: title: "Update invoice statuses". Don't drop the raw SQL from the body. The whole value of gating a text-to-SQL agent is that the human reviewing the action can catch exactly the kind of subtle bug — wrong column, wrong comparison operator, unquoted string — that a paraphrased summary would hide. Show the query, the bound parameters separately (never interpolated into the SQL string), and the estimated row count. That's the minimum a reviewer needs to make the call in a few seconds instead of having to go dig through logs.
Scopes and keys
Use a key scoped to actions only for this flow — it doesn't need watch or admin. If the same service also runs Impri watchers for something unrelated, use a separate key for that; see API keys.
Rejected, expired, and retried writes
Same rule as any gated action: rejected and expired both mean the write does not happen, no exceptions. Don't have the agent silently retry a rejected write with a narrower WHERE clause — if the query needs revision, that's a new action with a new preview the reviewer sees, not a retry that reuses the old approval.
For the full three-call pattern this builds on, see how to add human approval to an AI agent. For wiring this specifically into a Claude-based agent, see the Claude Agent SDK guide. For the MCP server details, see MCP.