# 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: ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); await runtime.images.build({ name: "sql-agent", recipe: { pip: ["psycopg[binary]", "duckdb"] } }); // once const RUNNER = `import json, os, sys import psycopg sql = 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'"), ); ``` ```python check import os from withruntime import Runtime runtime = Runtime() runtime.images.build(name="sql-agent", recipe={"pip": ["psycopg[binary]", "duckdb"]}) # once RUNNER = '''import json, os, sys import psycopg sql = 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](/docs/security#secrets-sandboxes-never-see) 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](/docs/networking#dedicated-outbound-addresses)). 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: ```ts check import { 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](/docs/javascript#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: ``` CPU: 2,000 × 45 s / 3,600 × $0.025 = $0.63 Memory: 2,000 × 10 min / 60 × 4 GiB × $0.0075 = $10.00 Address: 1 dedicated IPv4 × $5 = $5.00 Total: $15.63 ``` Without 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](/docs/pricing#network-products)). ## Start ```bash no-run npx withruntime sandbox run --trial -- sqlite3 --version ``` Approve the browser link once. Database ports beyond 80 and 443 open after the account's first top-up. Related: [data analysis agent](/use-cases/data-analysis-agent), [spreadsheet analysis](/use-cases/spreadsheet-analysis), [egress control](/glossary/egress-control), [run untrusted LLM code](/use-cases/run-untrusted-llm-code). ## Sources - [PostgreSQL client connection defaults](https://www.postgresql.org/docs/current/runtime-config-client.html): `statement_timeout` in milliseconds and `default_transaction_read_only`, read 25 September 2026. Facts on this page were checked on 25 September 2026.