Text-to-SQL Agent Generate · Execute · Repair · Verify

Text-to-SQL Agent with Self-Correction

Structured-data “RAG” where the answer is computed, not retrieved. The database schema goes in the prompt, an LLM writes SQL, and a generate → execute → fix loop self-corrects it against a strictly read-only database until it runs and actually answers the question.

Schema in prompt Generate SQL Guard · dry-run · execute Repair Verify
🔒 Strictly read-only Self-correcting repair loop Cheap EXPLAIN dry-run Live NDJSON streaming
127.0.0.1:8400 · Ask

Ask

qwen.qwen3-235b-a22b 🗄 retail_demo.db 🔒 read-only
Self-correction trajectory
Selected 2 tables
Need order_totals for spend per customer, customers for the country.
order_totalscustomers
Wrote SQL
Dry-run caught an errorwill repair
no such column: total_spent
Repaired SQL (attempt 2)repair
Fixed the column name and kept the join.
Executed · 5 rows · 3 msok
Checked the resultsanswers the question
SQLCopy
SELECT c.name, c.country, SUM(ot.order_total) AS total_spend
FROM customers c
JOIN order_totals ot ON ot.customer_id = c.customer_id
GROUP BY c.customer_id
ORDER BY total_spend DESC
LIMIT 5;
Result · 5 rows · 3 ms
namecountrytotal_spend
Priya SharmaIndia18,240.50
Jonas WeberGermany16,905.00
Grace OkoroUnited States15,110.75
Ken TanakaSingapore14,388.20
Sofia RossiAustralia13,642.00

The Ask view — the live self-correction trajectory (a dry-run error caught and repaired), the generated SQL, and the result table.

What it is

Ask your database in English — safely

Point it at any SQLite database and ask a question. It puts the schema in the prompt, has the model write a query, then runs a guard → dry-run → execute → repair loop until the SQL compiles, runs, and answers — all on a connection that physically cannot write. You get the answer, the exact SQL, and the result table.

Schema-in-prompt

The retrieval context is the database itself: annotated CREATE TABLE DDL, foreign keys, low-cardinality column values, and a few sample rows — enough for the model to write correct SQL, not guess it.

Self-correcting repair loop

The database is the grader. A query that won’t compile, errors, or returns the wrong thing is fed back with the exact error — bounded by max_repairs so it always halts.

Four-layer read-only safety

A static guard, an mode=ro connection, a deny-by-default authorizer, and a time/row budget. A generation has to pass all four before it touches a byte.

Cheap dry-run before you commit

An EXPLAIN step compiles the query — resolving tables, columns and the authorizer — without executing it, catching typos and unknown columns for the price of a parse.

Result self-check

After a query runs, a verifier judges whether the rows actually answer the question. A clear mismatch (wrong entity, aggregate vs. breakdown) triggers one semantic rewrite.

Live streaming trajectory

Nodes emit NDJSON events; the UI renders each stage — table selection, every generated query, the guard/dry-run/execute outcome, the verdict — then streams the answer token by token.

Bring your own SQLite

Upload a .db/.sqlite file or import a server-side path — it’s copied into a managed directory and only ever opened read-only, so the original is never touched. A retail demo DB ships built-in.

Per-thread memory

Each conversation is a thread whose memory lives in a LangGraph SqliteSaver checkpointer — so follow-ups like “now break that down by region” resolve against the previous turn.

Schema browser

The Database tab renders every table — columns with types, primary/foreign keys, row counts, and the same enum hints the model sees — so you know what you can ask.

Password-protected policy

Model, Bedrock connection, and the SQL execution policy — row cap, statement timeout, repair budget — live server-side in SQLite behind a password. The browser can tighten limits but never exceed the server ceilings.

Read-only by construction

Nothing trusts the model — four layers, cheapest first

The LLM only ever produces a string. Whether that string is allowed near the data is decided by four independent layers; a hostile or merely confused generation has to get past all of them.

1
Static guard

Comments stripped, string literals blanked, then the text must be exactly one statement beginning with SELECT/WITH. Stacked statements and a keyword blocklist (INSERT, UPDATE, DROP, ATTACH, PRAGMA…) are rejected before anything compiles.

2
Read-only connection

Every connection is opened file:…?mode=ro with PRAGMA query_only. SQLite refuses writes at the pager level, so even a bug in layer 1 cannot mutate the file.

3
Authorizer callback

SQLite calls back for every action it compiles. Only SELECT/READ/FUNCTION/RECURSIVE are allowed; everything else fails during preparation, before a single row is touched.

4
Budget

A progress handler aborts any query past the statement timeout, and rows are pulled with a hard cap — so a runaway result set is truncated, not materialized.

🔒 The EXPLAIN dry-run runs layers 1–3 without executing — that’s the agent’s cheap self-check before it commits to a real run.
Why it’s useful

When the answer is a query, not a paragraph

Classic RAG retrieves text. But a huge class of questions — “how many,” “top N,” “total by month” — are answered by computing over structured data. This is the shape of that work: safe, self-correcting, and auditable, because every answer ships with the exact SQL that produced it.

Self-service analytics

Let non-SQL teammates ask “top 5 customers by spend this quarter” without waiting on the data team — and see the query behind the number.

Exploring an unfamiliar schema

Dropped into a new database? Ask questions in English and watch the agent discover the tables, joins and column values as it goes.

Ops & support triage

“How many orders are stuck awaiting fulfilment?” answered live against a read-only replica, with zero risk of an accidental write.

Recurring reporting

Monthly revenue, breakdowns by region or channel — the same natural-language question re-runs against fresh data and returns a fresh table.

Embedded NL query

A safe read-only natural-language layer over a product database — the four-layer guard means untrusted questions can’t become dangerous SQL.

Tech stack

What it’s built on

A small, dependency-light Python backend and a vanilla-JS frontend with no build step. No vector store, no embeddings — the “index” is the live database schema.

Orchestration
LangGraph 1.xStateGraphConditional edgesSqliteSaver checkpointerstream_mode="custom"
Model / inference
Amazon BedrockAnthropic MantleClaudeQwen (OpenAI-compat)
Backend
FastAPIUvicornPydanticNDJSON streaming
Data & safety
SQLite mode=roAuthorizer callbackquery_onlyStatic SQL guardEXPLAIN dry-run
Storage
SQLite settingsManaged DB directoryPBKDF2 auth
Frontend
Vanilla JSNo build stepStreaming fetch readerManrope · JetBrains Mono
How it works

One graph, two loop guards

Five nodes: select_tables narrows the schema, generate_sql writes the query, execute guards/dry-runs/runs it, verify checks the result, and answer streams the reply.

START
  │
  ▼
select_tables ──▶ generate_sql ◀───────────────┐ ◀──────────────┐
                        │                       │ repair          │ rewrite
                        ▼                        │ (SQL error)     │ (wrong result)
                    execute ── guard → dry-run → run ──┘                │
                        │ ok                                       │
                        ▼                                          │
                     verify ── does it answer the question? ───┘
                        │ yes / budget spent
                        ▼
                     answer ──▶ END

Select the tables that matter

On a large schema, a planning step picks the smallest set of tables (and join-through tables) the question needs — so the SQL prompt carries focused DDL, not the whole database. Small schemas skip straight to generation.

Generate SQL from the schema in the prompt

The annotated DDL — types, keys, low-cardinality column values, sample rows — goes in the prompt, and the model returns one JSON object {sql, explanation}. The enum hints are what stop it guessing 'pending' when the data says 'awaiting_fulfilment'.

Guard, dry-run, execute

The query passes the static guard, then an EXPLAIN dry-run compiles it through the read-only authorizer, then it runs under a row-cap and timeout. Any failure records the exact SQLite error.

Repair on failure — the database is the grader

If the guard rejects it, the dry-run won’t compile, or execution errors, the loop returns to generate_sql with the error appended as the correction instruction — up to max_repairs times, then it gives up gracefully rather than guessing.

Verify the result actually answers the question

A self-check reads the question, the SQL, and a preview of the rows. A clear mismatch — wrong entity, an aggregate where a breakdown was asked for — triggers one semantic rewrite with the reviewer’s hint; otherwise the result is accepted.

Stream a grounded answer

The answer node writes plain English from the result table only, quoting the figures. Nodes call get_stream_writer() to emit tables, sql, guard, dry_run, execute, result, verify, text_delta and usage — forwarded to the browser as NDJSON. Every JSON-returning model call fails open, so a flaky judge can never trap the loop.

⚡ Two independent loop guards (max_repairs and one semantic rewrite) plus a graph recursion_limit guarantee the agent always halts.
Get started

Run it in two commands

Ships with a pre-seeded app_config.db, so the Bedrock region, key and model carry over — it runs out of the box, and auto-creates a retail demo database on first launch.

# install & launch
pip install -r requirements.txt
python server.py                 # → http://127.0.0.1:8400

# then, in the browser:
1. Database → use the built-in demo, or upload / import a .db
2. Ask      → “Top 5 customers by total spend, and which country each is in”
3. Settings → model, Bedrock connection & SQL policy (password: bala@143)
📄 This page is served live by the app at /overview — open http://127.0.0.1:8400/overview once the server is running.