
How we let Claude debug our production FMS in minutes (without giving it a footgun)
Last month, a Cursor agent deleted a production database at Pocket OS. A few weeks before that, Replit's agent did the same thing to another company during a code freeze. Every time one of these stories hits Hacker News, a wave of CTOs quietly ban AI agents from touching prod.
We understand the reflex. But we think it's the wrong lesson.
We've been running a Claude Code agent against our production FMS at Wove for months. It reads our production database, inspects live shipments, reconstructs why cross-validation failed on a specific customs entry, and pulls the actual PDFs our extraction pipeline was working from.
Turnaround on customer-reported bugs has gone from hours to minutes.
It has never deleted anything. It structurally cannot delete anything.
This post is about the pattern we built to make that true and why we think "give the agent raw psql" and "ban the agent from prod entirely" are both wrong answers.
The failure mode nobody talks about
The Pocket OS story gained traction because the agent deleted a database. That's the dramatic version. The far more common failure mode, the one that nobody writes articles about, is quieter:
Customer: "My shipment is stuck. Can you look?"
Engineer: pings backend dev on Slack
Backend dev: sshes to a bastion, opens psql, runs three queries, tails a log file, pieces together what happened, writes a summary
Engineer: forwards summary to customer
Elapsed: 45 minutes to 4 hours, depending on backend dev's calendar.
Every SaaS company runs this loop dozens of times a day. It burns your most expensive engineers on repetitive lookups. It gates every support conversation on backend eng availability. And it's the reason "we'll get back to you" has become the default customer experience.
The naive AI fix, "let the agent run psql for us," is what created the Pocket OS story. So teams bounce back to the manual loop and accept the slowness as the price of safety.
There's a third option. It just requires architecture, not vibes.
The pattern: agent-safe production access
The core idea is a small admin-gated HTTP surface (we call ours /_agent_query/*) that exposes exactly the read operations an agent needs to debug production, and nothing else. Not psql. Not the shell. Not the deployment pipeline. Not write endpoints.
Four properties make it agent-safe:
1. Read-only enforced at the database layer, not the application layer.
Every query the agent runs executes inside a Postgres transaction that begins with:
SET LOCAL transaction_read_only = on;
SET LOCAL statement_timeout = 5000;If the query builder above it has a bug, if the agent gets prompt-injected, if a malicious payload somehow slips through, Postgres refuses the write. The safety property isn't "we hope the agent doesn't do something bad." It's "the database will reject the write even if it tries."
This is the single most important line in the design. Everything else is defense in depth.
2. A structured query DSL, not raw SQL.
The agent doesn't send SQL. It sends JSON:
{
"table": "customs_entries",
"select": ["id", "status", "created_at"],
"where": [{ "column": "status", "op": "=", "value": "pending_review" }],
"orderBy": [{ "column": "created_at", "direction": "desc" }],
"limit": 10
}Every identifier (table, column, join targets) is whitelisted against information_schema before it goes anywhere near the SQL string. Unknown table → 400. Unknown column → 400. SQL injection isn't mitigated; it's structurally impossible, because there's no user-controlled SQL to inject into.
The DSL supports enough for real debugging: joins, or-groups, like/ilike, in/not_in, is_null. It deliberately doesn't support NOW(), INTERVAL, subqueries, UNION, CASE, or arbitrary functions. If the agent needs "customs entries created in the last hour," it computes the timestamp client-side and passes a literal. This is the same tradeoff GraphQL made: you lose expressiveness, you gain a machine-verifiable safety envelope.
3. Hard limits on blast radius.
bytea, pgvector, and tsvector columns are excluded from the default select. The agent can request them explicitly, but doesn't accidentally page a 400 MB embedding vector into its context window.4. Domain-specific endpoints for the common cases.
The generic query DSL is the escape hatch. On top of it, we expose typed endpoints for the entities our agent needs most:
GET /_agent_query/entities/shipment/:id/raw
GET /_agent_query/entities/shipment/:id/documents
GET /_agent_query/entities/shipment/:id/validation/latest
GET /_agent_query/entities/shipment/:id/merge/latest
GET /_agent_query/documents/:id/file # streams the actual PDF from S3
GET /_agent_query/documents/:id/parsed # what the LLM extractor produced
GET /_agent_query/logs/entities/:type/:id # per-entity integration logThese aren't strictly necessary. The agent could reconstruct all of them from POST /database/query plus S3 calls. But they turn "12 queries to reconstruct a shipment's state" into "one endpoint." Latency drops, and the agent's context stays clean enough to actually reason about the data.
What this looks like in practice
Yesterday a customer told us cross-validation was failing on a customs entry with no obvious reason. Old workflow:
1. Support forwards to engineering (~30 min queue).
2. Engineer opens psql, pulls the entry.
3. Engineer finds the linked documents.
4. Engineer pulls each document's raw_json to see what extraction produced.
5. Engineer runs cross-validation logic mentally against the extracted data.
6. Engineer writes up findings.
Total elapsed: 2-3 hours.
New workflow (verbatim, from the actual session):
User: shipment id 145... is failing cross-validation, why?
Agent, over the next 90 seconds:
1. GET /_agent_query/entities/customs_entry/145.../validation/latest → sees one critical finding on hsCode field
2. GET /_agent_query/entities/customs_entry/145.../documents → lists 3 attached documents
3. GET /_agent_query/documents/<invoice_id>/parsed → sees invoice extracted 8544.42.90
4. GET /_agent_query/documents/<packing_list_id>/parsed → sees packing list extracted 8544.49.90
5. Agent responds: "Invoice and packing list disagree on the HS code: invoice says 8544.42.90 (insulated cable with connectors), packing list says 8544.49.90 (insulated cable without connectors). The invoice is likely correct based on the product description. Want me to pull the actual PDFs to confirm?"
Total elapsed: 90 seconds. The engineer never got paged.
This is not the exciting part. The exciting part is that we didn't have to write a custom "explain why this entry failed" endpoint. We wrote generic primitives, and the agent composed them into a domain-specific investigation. Next week, when the failure mode is different, the primitives still work. That's what the abstraction is buying us.
What we deliberately did not build
No write endpoints. Not "just for annotations." Not "just for status updates." The moment there's a write path, the safety story collapses. If the agent needs to make a change, it opens a PR against our code or writes a script for a human to review. The debug surface is read-only, forever.
Token scoping is coarse-grained today. Our current debug credential grants broad internal read access rather than per-customer row-level scoping. That's an acceptable tradeoff for an internal tool used by a small, trusted engineering team, but it's not the bar we'd hold ourselves to for a customer-facing or productized version. Per-customer, per-user scoping is the next thing we'd build before this pattern left the walls of Wove engineering.
No raw SQL escape hatch. Every time we've been tempted to add one, we've found a way to extend the DSL instead. Once you add raw SQL "just for this one case," you've given up the whole safety property and you're back in Pocket OS territory.
Why the Cursor / Replit incidents are exactly the argument for this pattern
The two failure modes I've seen written up follow the same shape:
1. Human gives agent broad access (shell, database, deployment).
2. Agent gets confused: misreads a schema, follows a bad instruction, hallucinates a table name.
3. Confused agent issues a destructive command.
4. There is no layer beneath the agent that can refuse.
The lesson people are drawing is "agents can't be trusted with prod." I think the actual lesson is "unconstrained agents can't be trusted with prod." Any interface where the safety property lives in the agent's judgment will eventually fail, because the agent's judgment is probabilistic.
The interface we built moves the safety property below the agent, into the database and the query builder. The agent can be as wrong as it wants. The database still refuses to write, the identifier whitelist still rejects unknown tables, the row cap still fires. The agent's judgment gets to be a productivity multiplier without also being a load-bearing safety guarantee.
That's the trade nobody talks about. "Should we let AI touch prod?" is the wrong question. "What's the layer beneath the AI that catches its mistakes?" is the right one.
What we're doing next
We're going to open-source the generic parts (the auth flow, the query DSL, the middleware) as a small SDK you can drop into any Express + Postgres backend. Not because we think it's a business (we're a global trade software company, not a dev tools company), but because we think the pattern should exist in the world.
The dumbest version of AI-in-production is "give the agent psql and hope." The safest version is "ban the agent entirely." Neither of those is good enough. The middle path (structured, read-only, audited, capped) is what turns AI from a liability into an operations multiplier. We've been running on it for months. Our support turnaround is in minutes. Our database is intact.
Turns out you can have both.
If you want early access, email us. If you're already running something similar, we would love to compare notes.
