How to build a text-to-SQL agent that queries your database from a sandbox
Run the model's SQL from a sandbox that can reach only your database, over a read-only login with a statement timeout, and return the rows.
On Runtime 2,000 SQL analysis sessions cost about $15.63 a month, including a dedicated IPv4 address your database can allow-list, at the rates in force on 25 September 2026. The sandbox is billed for the CPU the queries and the model's pandas code use, so the minutes spent waiting on the model and the database cost little. A paid sandbox reaches Postgres, MySQL or any other database port with no setup, and its network rules keep it from reaching anything else.
The short answer
Put a small runner in the sandbox, send each query on standard input, and pass
the connection string through env, never in the command line:
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();await runtime.images.build({ name: "sql-agent", recipe: { pip: ["psycopg[binary]", "duckdb"] } }); // onceconst RUNNER = `import json, os, sysimport psycopgsql = sys.stdin.read()opts = "-c statement_timeout=15000 -c default_transaction_read_only=on"with psycopg.connect(os.environ["DATABASE_URL"], options=opts) as conn: cur = conn.execute(sql) cols = [c.name for c in cur.description or []] rows = cur.fetchmany(200) if cur.description else []print(json.dumps({"columns": cols, "rows": rows}, default=str))`;await using sbx = await runtime.sandboxes.create({ image: "sql-agent", funding: "paid", network: { internet: true, allow: ["db.example.com"], connect: ["db.example.com:5432"] },});await sbx.files.write("/workspace/run_sql.py", RUNNER);// The tool the model calls: run_sql(query).export async function runSql(query: string) { const run = await sbx.exec(["python3", "/workspace/run_sql.py"], { stdin: query, env: { DATABASE_URL: process.env.READONLY_DATABASE_URL ?? "" }, timeoutMs: 30_000, }); return run.exitCode === 0 ? run.stdout : `error: ${run.stderr.slice(-2000)}`;}console.log( await runSql("select count(*) from orders where created_at > now() - interval '7 days'"),);Pythonimport osfrom withruntime import Runtimeruntime = Runtime()runtime.images.build(name="sql-agent", recipe={"pip": ["psycopg[binary]", "duckdb"]}) # onceRUNNER = '''import json, os, sysimport psycopgsql = sys.stdin.read()opts = "-c statement_timeout=15000 -c default_transaction_read_only=on"with psycopg.connect(os.environ["DATABASE_URL"], options=opts) as conn: cur = conn.execute(sql) cols = [c.name for c in cur.description or []] rows = cur.fetchmany(200) if cur.description else []print(json.dumps({"columns": cols, "rows": rows}, default=str))'''with runtime.sandboxes.create( image="sql-agent", funding="paid", network={"internet": True, "allow": ["db.example.com"], "connect": ["db.example.com:5432"]},) as sbx: sbx.files.write("/workspace/run_sql.py", RUNNER) def run_sql(query: str) -> str: # the tool the model calls run = sbx.exec(["python3", "/workspace/run_sql.py"], stdin=query, env={"DATABASE_URL": os.environ["READONLY_DATABASE_URL"]}, timeout_ms=30_000) return run.stdout if run.exit_code == 0 else "error: " + run.stderr[-2000:] print(run_sql("select count(*) from orders where created_at > now() - interval '7 days'"))The runner caps every answer at 200 rows, so a select * cannot flood the
model's context, and a Postgres error comes back as text the model can read
and correct.
Four guards on a model's SQL
A text-to-SQL agent writes queries nobody reviewed. Stack the guards so no one of them has to be perfect:
| Guard | Where it is enforced | What it stops |
|---|---|---|
A database role with SELECT only |
Your database | Any write, whatever the query says |
default_transaction_read_only=on |
Each session the runner opens | Writes to non-temporary tables |
statement_timeout=15000 |
Each statement, in milliseconds | A cross join that runs for an hour |
| The sandbox's network rules | Runtime's host, outside the sandbox | Query results sent anywhere but your database |
The last guard matters most when the model also runs Python: with allow
listing only the database's host, code that tries to post the rows to another
server is refused, and root inside the sandbox cannot change the rules.
A database password is not HTTPS, so Runtime's
secrets cannot keep it out of the
sandbox the way they keep API keys out. Pass it with env, which is never
echoed back and is recorded in journals only as a hash, and use a login that
can only read.
Reach a database that is not on the internet
| Where the database is | How the sandbox reaches it |
|---|---|
| Public, open to the world | Directly: a paid sandbox reaches any public host on any port |
| Public, behind an IP allow list | A dedicated outbound IPv4 address for the account, $5 per 30-day month, to add to the list |
| In a private network or VPC | A WireGuard tunnel from a machine in that network, $5 per 30-day month with up to 16 peers |
| A file: SQLite, CSV or Parquet | Upload it; sqlite3 is in the default image and DuckDB is one pip line |
With runtime address reserve, every sandbox of the account sends from one
address no other account uses while you hold it
(networking). The trial
reaches ports 80 and 443 only, so database ports need an account that has
bought credit.
Let the model analyze what it fetched
SQL answers the question; pandas often explains it. The interpreter in the same sandbox keeps a data frame between the model's steps, and matplotlib charts come back as PNG:
TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create({ network: { internet: false } });await sbx.files.write( "/workspace/rows.json", '{"columns":["day","n"],"rows":[["mon",4],["tue",9]]}',);const cell = await sbx.interpreter.run( "import json, pandas as pd\nr = json.load(open('rows.json'))\n" + "df = pd.DataFrame(r['rows'], columns=r['columns'])\ndf.describe()",);console.log(cell.results[0]?.data["text/plain"]);For an agent that should explore a schema on its own, the Postgres server in Runtime's MCP catalog starts inside the sandbox with one call (MCP servers in a sandbox).
What a SQL agent needs
| Need | How Runtime covers it |
|---|---|
| Database drivers | Any from pip or npm, built into an image once |
| Reach only the database | network.allow and connect, enforced on the host |
| A stable source address | Dedicated IPv4 or IPv6 for the account |
| Private databases | WireGuard tunnel into your own network |
| Password kept out of logs | env on exec, recorded as a hash |
| Runaway queries | timeoutMs on each call, plus the database's own statement_timeout |
| One user's data kept apart | A Firecracker microVM with its own kernel per sandbox |
What it costs
Take 2,000 sessions a month. Each keeps a 2 vCPU, 4 GiB sandbox running 10 minutes while the model asks several questions, and the queries and pandas cells use 45 CPU-seconds; the account holds one dedicated IPv4 address:
TextCPU: 2,000 × 45 s / 3,600 × $0.025 = $0.63Memory: 2,000 × 10 min / 60 × 4 GiB × $0.0075 = $10.00Address: 1 dedicated IPv4 × $5 = $5.00Total: $15.63Without the dedicated address it is $10.63. Runtime charges $0.025 per vCPU-hour of measured CPU and $0.0075 per GiB-hour of memory; custom domains, TCP ports and IPv6 are included (pricing).
Start
Terminalnpx withruntime sandbox run --trial -- sqlite3 --versionApprove the browser link once. Database ports beyond 80 and 443 open after the account's first top-up.
Related: data analysis agent, spreadsheet analysis, egress control, run untrusted LLM code.
Sources
- PostgreSQL client connection defaults:
statement_timeoutin milliseconds anddefault_transaction_read_only, read 25 September 2026.
Facts on this page were checked on 25 September 2026.