Runtime

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.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).

Start

Terminalnpx 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, spreadsheet analysis, egress control, run untrusted LLM code.

Sources

Facts on this page were checked on 25 September 2026.