On Runtime, 2,000 SQL analysis sessions cost about $15.63 a month. This includes a dedicated IPv4 address your database can allow-list. These are the rates in force on 26 October 2026. The sandbox is billed for the CPU that the queries and the model's pandas code use. 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. Its network rules block every other destination.
The short answer
Put a small runner in the sandbox. Send each query on standard input. Pass the
connection string through env, never on 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", 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", 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 returns at most 200 rows for each answer. A select * therefore
cannot flood the model's context. A Postgres error comes back as text that the
model can read and correct.
Four guards on a model's SQL
A text-to-SQL agent writes queries that nobody reviewed. Use all four guards together, so that no single guard 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. Root inside the sandbox cannot change the rules.
A database password is not an HTTPS credential. So Runtime's
secrets cannot keep it out of the
sandbox, the way they keep API keys out. Pass the password with env. Runtime
never echoes these values back, and journals record them only as a hash. 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 ips reserve, every sandbox of the account sends from one
address. No other account uses that address while you hold it
(networking). Without credit, a
sandbox reaches ports 80 and 443 only. Database ports therefore need an account
that has bought credit.
Let the model analyze what it fetched
SQL answers the question, and pandas often explains it. The interpreter in the same sandbox keeps a data frame between the model's steps. matplotlib charts come back as PNG files:
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 for 10 minutes while the model asks several questions. 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, the total 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 sandboxes run -- 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 26 October 2026.
Facts on this page were checked on 26 October 2026.