Read your app's data: schema and SQL
Once an app has a managed database, you can inspect and read its data through the agent surface, without ever putting the connection string in front of the agent. This guide covers the two direct-data-access tools and how they fit together:
describe_database: the schema (shape only, never row values). Free, all plans.query_database: a single read-only SQL query, capped and audited. Paid.
Both work on a prod app or a preview app (a branched preview carries its own managed-database record). Both are also available on the REST /api/v1 surface (paid plans — REST API access is a paid capability).
Heads-up on previews: a
clone-mode preview's branched database contains real production rows copied at branch time. Reading it reads prod-shaped data, so treat it accordingly.
Inspect the shape: describe_database
Start here. describe_database returns the schema only: tables, columns (name, type, nullability), primary keys, indexes, and an approximate row count per table. It never returns a single row of data, so it's free on every plan (including the Explore free tier).
describe_database({ applicationId })
describe_database({ applicationId, table: "todos" }) // just this table
describe_database({ applicationId, limit: 10, offset: 10 })
{
"ok": true,
"applicationId": "app_123",
"databaseManaged": true,
"provider": "postgres",
// Live size vs. the org's `maxDatabaseGb` ceiling. Present whenever the app HAS
// a managed database (including the `describe_unsupported` case, where size is
// still measurable). `measuredBytes`/`measuredGb` are null when size is unknown.
"storage": {
"measuredBytes": 8388608,
"measuredGb": 0.01,
"ceilingGb": 10,
"overCeiling": false
},
"schema": {
"tables": [
{
"name": "todos",
"columns": [
{ "name": "id", "dataType": "uuid", "nullable": false },
{ "name": "title", "dataType": "text", "nullable": false }
],
"primaryKey": ["id"],
"indexes": [{ "name": "todos_pkey", "columns": ["id"], "unique": true }],
"approxRowCount": 42
}
],
"totalTables": 1
},
"limit": 25,
"offset": 0,
"hasMore": false
}
The table list is a window. Every table comes back with all of its columns and indexes, so a wide schema described whole is a big result. table narrows to one table by exact name — the filter is pushed into the catalog queries, so the rest of the schema is never read, and an unknown name returns an empty schema.tables with totalTables: 0 rather than an error. Otherwise limit (default 25, max 200) and offset page the tables in name order, with schema.totalTables and hasMore/nextOffset telling you what you haven't seen.
Recoverable errors (success channel)
errorCode | Meaning |
|---|---|
no_managed_database | The app has no DeployMill-managed database. Add a database block to .deploymill/project.json and reconcile_project. |
describe_unsupported | The app is a legacy one whose database is hosted by an external vendor this server can't introspect. Use that vendor's own console. |
control_plane_database | The app is DeployMill's own control plane. Its database holds platform-wide authentication, audit and secret records, so it isn't readable through the per-app tools. |
Run a read-only query: query_database
query_database runs one read-only SQL statement and returns capped rows. This is the agent's actual row-data surface. It requires the direct data access entitlement, which every paid plan has (any org with a card on file) and the free Explore tier does not — see Upgrading.
query_database({ applicationId, sql: "select id, title from todos order by created_at desc", maxRows: 100 })
{
"ok": true,
"applicationId": "app_123",
"provider": "postgres",
"columns": ["id", "title"],
"rows": [{ "id": "…", "title": "ship it" }],
"rowCount": 1,
"truncated": false
}
- Read-only is enforced by the database, not by reading your SQL. Each app
gets a dedicated Postgres read-only role — granted
CONNECT,USAGEandSELECT, and nothing else — andquery_databaseopens its connection as that role, inside a read-only transaction with a server-enforced statement timeout. A write is refused by Postgres itself, whatever the statement looks like. There is no allowlist of read-only-looking keywords, so any statement that genuinely reads is allowed (including ones a keyword check would wrongly reject, like(SELECT …) UNION (SELECT …)), and a write comes back asnot_read_only. - Single statement only: a
;-separated batch produces several result setsand this tool returns one, so a batch is refused with
not_single_statement. maxRowsdefaults to 1000 and is hard-capped at 1000. When more rows match,truncated: true, so narrow the query or paginate in SQL.- Secret-named columns are redacted: cells come back as
[redacted]when thecolumn name contains (case-insensitively) any of
password,passwd,pwd,secret,token,api_key,apikey,private_key,access_key,client_secret,cred,credential,hash,salt,pin,cvv,ssn,mfa, orotp. It's a substring match, souser_password_hash,apikeys, andspinnerall redact — over-redaction is the deliberate failure mode. Aliasing a denylisted column (select password as x) does not defeat it. Two fallbacks over-redact every column in the result rather than risk a leak: when a denylisted word appears anywhere in the SQL but no output column could be matched to it (a subquery, an expression), and when the statement serializes or casts a whole row into one innocuously-named column (row_to_json(t),to_jsonb(t),json_agg(t),row(...),t::text,CAST(t AS jsonb), …). So a query that mentions a secret column, or projects whole rows, can come back entirely[redacted]— select the columns you actually need by name to avoid it. - Every call is audited (the SQL text and row count, never the row data).
Recoverable errors (success channel)
errorCode | Meaning |
|---|---|
no_managed_database | The app has no managed database. |
control_plane_database | The app is DeployMill's own control plane — its database is never queryable through the per-app tools. |
not_read_only | The database refused the statement: the connection may only read. Rewrite it as a read. |
not_single_statement | More than one ;-separated statement was sent. Send one at a time. |
query_unsupported | The app is a legacy one whose externally-hosted database has no clean read-only SQL path. Use that vendor's own SQL editor. |
query_failed | The database rejected the query (syntax error, write blocked by the read-only transaction, timeout, …). |
upgrade_required | The org's plan doesn't include direct data access. Includes an upgradeGuide pointer (and upgradeUrl when configured). |
data_access_disabled | An org admin turned the direct-data-access governance toggle off, even on a paid plan. |
A typical flow
describe_databaseto learn the tables and columns (free; do this first).query_databaseto read the rows you need (read-only, capped, audited).
From the dashboard
Everything above is also on the app's Database tab in the dashboard, for when you want to look at your data yourself rather than ask an agent:
- Tables lists the live schema — columns, types, primary keys, indexes and
approximate row counts — plus the database's measured size against your plan's limit. This is the
describe_databaseview, so it's free on every plan. - Run a query is a read-only SQL console over
query_database. Each tablealso has a Preview rows button that drops a starter query into it.
The dashboard calls the same tools an agent does, so the rules are identical: the same read-only role, the same 1000-row cap, the same redaction of secret-named columns, and the same audit entry. Nothing is available in the UI that isn't available to an agent. If your plan doesn't include direct data access, the schema browser keeps working and the console explains the gate.
Security notes
- The app's real
DATABASE_URLis the canonical "secret the agent never sees".None of these tools return it.
query_databaseis gated by the direct data access entitlement and can beturned off org-wide by an admin governance toggle. It returns coded
upgrade_required/data_access_disabledrather than a silent dead-end.- Reads are attributable: every
query_databasecall lands in the audit trail.