Teaching our AI agent what's actually in your database
Max Musing
Max MusingFounder and CEO of Basedash
· July 21, 2026

Max Musing
Max MusingFounder and CEO of Basedash
· July 21, 2026

Most of what our chat agent does is write SQL against a database it has never seen before. A user asks “which customers churned last quarter”, and the agent has to figure out that churn lives in analytics.subscription_events, that the status column is an enum with a canceled value, and that the customers table with 40M rows is the real one while customers_tmp is someone’s forgotten experiment.
For a long time our answer to this was: dump the schema into the prompt and hope. That works great on a 12-table Postgres database. It falls apart on a 304-table warehouse, and we watched it fall apart in a specific, measurable way: the agent started running its own information_schema queries against customer warehouses to rediscover what we already knew.
In one benchmark run we counted 140 introspection queries against the customer’s warehouse. After the changes in this post, that number is 2.
Every agent turn gets a <database> block: the connected database’s schema rendered as YAML, with tables, columns, types, foreign keys, and enums. The renderer has a character budget (25,000 by default), because schema context competes with conversation history and tool results for the context window.
Big schemas don’t fit, so we run an ordered pipeline of compression candidates and take the first one that fits the budget. Each step gives up a little fidelity:
staging, dev, and prod schemas with identical shapes, so we render one and note “same structure also applies to: staging, dev”.The detail that matters most in that list is what happens to the tables we cut. We don’t silently drop them. The rendered YAML keeps an index line:
# other tables (details omitted — use describe_tables): invoices, refunds, tax_rates, ...
An agent that has never heard of a table will guess a name or go introspect the live database. An agent that has seen the name but not the details will ask for details, which is exactly the behavior we want.
On large warehouses the compression ladder bottoms out hard. A 304-table warehouse compressed to fit 25k characters ends up as mostly table names, and the agent has to fill the gaps somehow.
Its only option used to be running SQL against the customer’s warehouse: information_schema.columns queries, SELECT * FROM x LIMIT 5 probes, that kind of thing. This is slow (each one is a network round trip to the customer’s database), and on Snowflake or BigQuery it’s also money, since any query can wake a paused warehouse or bill scanned bytes. It’s a bad feeling to know a customer’s compute woke up because our agent forgot what a column was called.
The fix has two halves: give the agent tools to query the schema metadata we already have, and make that metadata rich enough that it rarely needs anything else.
We sync schema metadata into our own Postgres whenever a data source connects or refreshes. That means we can answer “what tables mention subscriptions” without touching the customer’s warehouse at all. Two new tools expose that:
search_catalog does keyword search over table names, column names, descriptions, and enum values. The ranking took a few iterations to get right. Tokens match at identifier-segment boundaries, so etl matches reverse_etl_sync because it’s a segment of the name, but voice no longer matches invoice. Exact name matches rank above prefix matches, which rank above segment matches, which rank above description mentions.
A couple of behaviors we added after watching agents flail:
run_query instead of rephrasing the search 4 more times.sync_mode and one table’s boilerplate would otherwise bury every other result.describe_tables returns full metadata for up to 10 tables in one call: every column, type, nullability, foreign keys in both directions, enum values, and the profiling stats below. It also resolves sloppy names. An exact schema.table match wins, then a unique unqualified table name, then a unique near-miss within a small Levenshtein distance. Agents guess table names slightly wrong constantly, and each failed lookup used to cost a full model turn. Now describe_tables("public.usres") just works and tells the agent what it interpreted.
The tool descriptions actively push toward batching: describe all your candidate tables in one call, in parallel with other tool calls. Tool-call round trips are the latency budget in an agent loop, so the discovery phase should be as wide and as shallow as possible.
Names and types only get you so far. The queries agents actually get wrong are the ones that need to know what’s in a column: is status a lowercase active or an uppercase ACTIVE? Is deleted_at null for 95% of rows or 5%? Is this table 300 rows of config or 40M rows of events?
So after each schema sync we run a profiling pass that collects, per table:
updated_at-like column.The obvious way to do this is COUNT(*) and SELECT DISTINCT over everything, which would be a great way to torch a customer’s warehouse bill. Almost all of the design here is about keeping the probes cheap and bounded:
COUNT(*). Postgres statistics, Redshift’s svv_table_info, and so on. (Redshift serializes its numeric row counts as strings like "12400.000000", which is the kind of thing you only learn by running this against real warehouses.)pg_stats already knows null fractions and distinct counts for analyzed tables. If the engine says a column has 40,000 distinct values, we skip the sample probe entirely, since we’d find more than 8 values and store nothing.SELECT DISTINCT ... LIMIT 9. If we get 9 rows back, the column is high-cardinality and we store only a “9+” lower bound. If we get 8 or fewer, we know the list is complete, and we store it with that guarantee.There’s also a hard policy layer: columns marked as obscured in Basedash are never sampled, and neither is any column whose name looks like an identifier, email, URL, token, or hash. Sample values exist to teach the agent the shape of categorical data, and identifiers teach it nothing anyway.
One subtle bit we got wrong at first: when the budget expires mid-table, you can’t persist half a column’s results. A column whose distinct-sample probe ran but whose null-fraction probe didn’t would overwrite last week’s good null fraction with an unknown. So a snapshot only includes columns whose probes fully completed, and persistence keeps the previous values for the rest.
All of this renders back into the same YAML the agent already reads, as comments:
subscription_events: # ~2.4M rows, newest 2026-07-20
columns:
- id # uuid, PRIMARY KEY
- status # text /* all values: active, canceled, past_due, trialing | null fraction: 0 */
- canceled_reason # text /* distinct values: 9+ | null fraction: 0.87 */
- customer_id # uuid, FK → customers.id
The all values: label is a promise: the list was exhaustive at profiling time, so the agent can treat it like an enum and write WHERE status = 'canceled' without a scouting query first. distinct values: 9+ is the opposite signal, don’t bother enumerating this. And the row count plus newest-row date answers “is this table real and current” before the agent commits to it.
Freshness gets a staleness guard: if the profile itself is more than 7 days old, we drop the newest-row date from the render, because at that point it misleads more than it helps. Row counts drift much slower, so those stay.
We benchmark the chat agent on an internal eval suite (Chat Bench) that runs scenarios against a 304-table Postgres warehouse, with graded answers and full SQL traces. Two numbers from the catalog rollout:
| Metric (per full bench run) | Before | After |
|---|---|---|
| Introspection queries on the customer’s DB | 140 | 2 |
| Chat Bench score (GPT-5.4) | 47.57% | 51.53% |
The score delta is directional (the runs were on different days and the suite has real variance), but the introspection number is the one we were after, and it’s not subtle. Latency and cost stayed flat: the catalog tools added tool calls, but each one is a local Postgres read instead of a warehouse round trip, and the fused search results removed about as many calls as the new tools added.
The remaining 2 introspection queries are legitimate, for what it’s worth: dialect-specific edge cases the catalog doesn’t model, which is exactly the fallback role we want live introspection to have.
If you like this genre of work, we’ve written up a few adjacent pieces: how a timestamp was busting our prompt cache, paginating user-written SQL across every dialect we support, and what we learned running background jobs on Postgres, which is the queue this profiling job runs on.
And if you want to see the agent that reads all this metadata, Basedash is an AI-native BI platform that turns questions into charts and dashboards on top of your own database.
Written by

Founder and CEO of Basedash
Max Musing is the founder and CEO of Basedash, an AI-native business intelligence platform designed to help teams explore analytics and build dashboards without writing SQL. His work focuses on applying large language models to structured data systems, improving query reliability, and building governed analytics workflows for production environments.
Basedash lets you build charts, dashboards, and reports in seconds using all your data.