{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Bonnard Blog",
  "home_page_url": "https://bonnard.dev/blog",
  "feed_url": "https://bonnard.dev/feed.json",
  "description": "Semantic layers, agentic analytics, MCP, and shipping governed data to AI agents.",
  "items": [
    {
      "id": "https://bonnard.dev/blog/mcp-dashboards-and-embedded-charts",
      "url": "https://bonnard.dev/blog/mcp-dashboards-and-embedded-charts",
      "title": "Views and embed mode: two new ways to use MCP Charts",
      "summary": "Your MCP server can now return a whole dashboard from one tool call, and render the same chart inside your own admin console. What shipped in @bonnard/mcp-charts since the visualize tool.",
      "content_html": "<p>We’ve shipped two features requested by early adopters of our open-source MCP Charts package.</p>\n<p><strong>Views</strong> bring BI to your MCP server: you define a dashboard or a single chart once, and the agent renders it by name instead of writing SQL from scratch every time. The package adds two agent tools for this: <code>explore_views</code> lists what’s available, and <code>render_view</code> renders the one the agent picks.</p>\n<p>The second feature, <strong>embed mode</strong>, renders the same charts inside your own web app, not just inside a chat client. Serve the widget from your own route, drop it in an iframe, and it renders chrome-less.</p>\n<h2 id=\"views-a-dashboard-from-one-tool-call\">Views: a dashboard from one tool call</h2>\n<p>A view here is a real, named thing you author: a grid of KPI tiles, chart cells, and text blocks that renders as a dashboard. You write the queries and choose the layout. The agent picks which view to show, not what goes in it.</p>\n<p>One <code>addViews</code> call gives your server two tools: one to list what’s published, one to render it by id.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addViews, chartCell } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { z } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"zod\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addViews</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  views: [</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">    {</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      id: </span><span style=\"color:#032F62\">\"sales_overview\"</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      title: </span><span style=\"color:#032F62\">\"Sales overview\"</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      kind: </span><span style=\"color:#032F62\">\"dashboard\"</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      params: { region: z.</span><span style=\"color:#6F42C1\">enum</span><span style=\"color:#24292E\">([</span><span style=\"color:#032F62\">\"EU\"</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">\"US\"</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">\"APAC\"</span><span style=\"color:#24292E\">]).</span><span style=\"color:#6F42C1\">optional</span><span style=\"color:#24292E\">() },</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">      render</span><span style=\"color:#24292E\">: ({ </span><span style=\"color:#E36209\">region</span><span style=\"color:#24292E\"> }) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">        columns: </span><span style=\"color:#005CC5\">2</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">        items: [</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          { type: </span><span style=\"color:#032F62\">\"kpi\"</span><span style=\"color:#24292E\">, label: </span><span style=\"color:#032F62\">\"Revenue\"</span><span style=\"color:#24292E\">, value: </span><span style=\"color:#005CC5\">336800</span><span style=\"color:#24292E\">, format: </span><span style=\"color:#032F62\">\"currency\"</span><span style=\"color:#24292E\"> },</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">          chartCell</span><span style=\"color:#24292E\">(</span><span style=\"color:#6F42C1\">monthlyRows</span><span style=\"color:#24292E\">(region), { chartType: </span><span style=\"color:#032F62\">\"line\"</span><span style=\"color:#24292E\">, title: </span><span style=\"color:#032F62\">\"Revenue by month\"</span><span style=\"color:#24292E\">, span: </span><span style=\"color:#005CC5\">2</span><span style=\"color:#24292E\"> }),</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">          chartCell</span><span style=\"color:#24292E\">(</span><span style=\"color:#6F42C1\">regionRows</span><span style=\"color:#24292E\">(region), { chartType: </span><span style=\"color:#032F62\">\"bar\"</span><span style=\"color:#24292E\">, title: </span><span style=\"color:#032F62\">\"Revenue by region\"</span><span style=\"color:#24292E\"> }),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">        ],</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      }),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">    },</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<figure>\n  <img src=\"/images/content/mcp-dashboard-in-claude.png\" alt=\"A multi-cell dashboard with KPI tiles, a line chart, a bar chart, and a text summary, rendered in Claude from one render_view call\" />\n  <figcaption>One <code>render_view</code> call, one dashboard: KPI tiles, a line chart, a bar chart, and a text summary, rendered inside Claude.</figcaption>\n</figure>\n<p>Views take typed parameters, so one dashboard serves every region instead of being rebuilt per filter. Need just one chart from it on its own? Give the cell an <code>id</code> and the agent can call that chart up directly, no extra tool required.</p>\n<p>This is the difference between an agent that improvises a chart and an agent that shows your customers a number you already stand behind. The queries, the layout, and the wording are yours. The agent’s job is retrieval.</p>\n<p>Full detail is in the <a href=\"https://docs.bonnard.dev/mcp-charts/views\">named views guide</a> and the <a href=\"https://docs.bonnard.dev/mcp-charts/dashboards\">dashboards reference</a>. <a href=\"https://github.com/bonnard-data/mcp-charts/tree/main/examples/dashboard\"><code>examples/dashboard</code></a> is a runnable six-view server you can point a client at.</p>\n<h2 id=\"the-same-charts-inside-your-own-product\">The same charts inside your own product</h2>\n<p>Most products that add an MCP server still have a web app, and that web app still needs charts. Embed mode lets one renderer serve both.</p>\n<p>The package exports <code>WIDGET_HTML</code>, the same self-contained file the MCP resource serves. Serve it from your own route, point an iframe at it with an <code>#embed</code> fragment, and post it a spec. The widget drops its padding, cell borders, and title, so your card provides the chrome.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { WIDGET_HTML } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#24292E\">app.</span><span style=\"color:#6F42C1\">get</span><span style=\"color:#24292E\">(</span><span style=\"color:#032F62\">\"/chart-widget\"</span><span style=\"color:#24292E\">, (</span><span style=\"color:#E36209\">_req</span><span style=\"color:#24292E\">, </span><span style=\"color:#E36209\">res</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> res.</span><span style=\"color:#6F42C1\">type</span><span style=\"color:#24292E\">(</span><span style=\"color:#032F62\">\"html\"</span><span style=\"color:#24292E\">).</span><span style=\"color:#6F42C1\">send</span><span style=\"color:#24292E\">(</span><span style=\"color:#005CC5\">WIDGET_HTML</span><span style=\"color:#24292E\">));</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"html\"><code><span class=\"line\"><span style=\"color:#24292E\">&#x3C;</span><span style=\"color:#22863A\">div</span><span style=\"color:#6F42C1\"> class</span><span style=\"color:#24292E\">=</span><span style=\"color:#032F62\">\"my-card\"</span><span style=\"color:#24292E\">></span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  &#x3C;</span><span style=\"color:#22863A\">iframe</span><span style=\"color:#6F42C1\"> id</span><span style=\"color:#24292E\">=</span><span style=\"color:#032F62\">\"rev\"</span><span style=\"color:#6F42C1\"> src</span><span style=\"color:#24292E\">=</span><span style=\"color:#032F62\">\"/chart-widget#embed\"</span><span style=\"color:#6F42C1\"> sandbox</span><span style=\"color:#24292E\">=</span><span style=\"color:#032F62\">\"allow-scripts\"</span><span style=\"color:#24292E\">>&#x3C;/</span><span style=\"color:#22863A\">iframe</span><span style=\"color:#24292E\">></span></span>\n<span class=\"line\"><span style=\"color:#24292E\">&#x3C;/</span><span style=\"color:#22863A\">div</span><span style=\"color:#24292E\">></span></span></code></pre>\n<p>Wait for the widget’s ready message, post it a <code>ChartSpec</code>, a single dashboard cell, or a whole dashboard with the cell you want addressed by id. It reports back what height it needs when the content has one, which KPI, text, and table cells do and charts do not.</p>\n<p><code>sandbox=\"allow-scripts\"</code> is all the widget needs. It makes no network requests of its own, every payload string is escaped before it reaches the DOM, and it only accepts messages from its own parent window. Theming goes through a small set of validated tokens rather than CSS overrides, so a caller cannot inject a stylesheet into the frame. Charts follow a light or dark theme; the tokens cover the HTML surface around them.</p>\n<p>The flags, the message shapes, the token grammar, and the stability contract are in the <a href=\"https://docs.bonnard.dev/mcp-charts/embed-mode\">embed mode guide</a>, with a runnable two-card page in <a href=\"https://github.com/bonnard-data/mcp-charts/tree/main/examples/embed\"><code>examples/embed</code></a>.</p>\n<h2 id=\"smaller-changes-in-the-same-releases\">Smaller changes in the same releases</h2>\n<p>Bar charts stay vertical unless you ask for <code>horizontal: true</code>. The resolver used to flip any categorical bar chart with more than eight rows, which sent a twelve-month series sideways. Row count was standing in for label width, and it was bad at it.</p>\n<p>Text tiles render markdown, so bold, links, lists, and headings in a summary come out formatted instead of as literal syntax. Raw HTML never passes through, which keeps it safe for model-written or user-typed text.</p>\n<p>Table tiles scroll inside their cell with a sticky header instead of growing the card without limit, and a short cell no longer stretches to match a taller chart beside it.</p>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>Add it to an existing MCP server with <a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>npm install @bonnard/mcp-charts</code></a>, then <a href=\"https://docs.bonnard.dev/mcp-charts/getting-started\">start here</a>. The source is on <a href=\"https://github.com/bonnard-data/mcp-charts\">GitHub</a>. Bonnard never connects to your database: you pass a callback, and we render the rows it returns.</p>\n",
      "date_published": "2026-08-03T00:00:00.000Z",
      "date_modified": "2026-08-03T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/warehouse-as-semantic-layer",
      "url": "https://bonnard.dev/blog/warehouse-as-semantic-layer",
      "title": "The Semantic Layer You Already Have",
      "summary": "Before adopting Cube.dev or a custom semantic layer, use your warehouse's native metadata (comments, keys, enums, stats) as a lightweight one for agents.",
      "content_html": "<p>Semantic layers are having a revival. With agents now querying data across businesses, the thinking goes that they need a specific “language” to query it reliably.</p>\n<p>Your company is openly discussing whether adopting one makes sense. Management are still bruised from the last failed warehouse-querying agent implementation and are looking for a better solution. Someone mentions Cube.dev, another suggests building a custom semantic layer.</p>\n<p>As the lead data engineer in your company, you’re thinking practically about the challenges of implementing a semantic layer: another abstraction to be maintained; a second copy of business logic that easily drifts as the data models and metrics evolve; another endpoint that needs to be hosted and monitored; a new framework to learn.</p>\n<p>Before committing to anything, there’s another option you should consider: <strong>leveraging your database’s native metadata and functions as a lightweight semantic layer.</strong></p>\n<p>It’s a surprisingly large lever. In our own agent testing, adding authored descriptions to an opaque schema took an agent’s first-try table-finding from roughly one in four to near-perfect.</p>\n<p>If you’re already using dbt, you’re likely most of the way there already, and maintaining a single source of truth is trivial.</p>\n<p>Here’s how you can achieve this in a few days’ work.</p>\n<h2 id=\"1-create-your-agent-gold-layer\">1. Create your agent gold layer</h2>\n<p><strong>Instead of revealing all your marts to agents, curate a helpful set of views (or tables) and columns that meet the specific contexts and use cases you want to provide.</strong> This is arguably where the most work will go. Aim for 20-30 views to begin with, then later you can scale up to 50-100 (which agents can easily handle with a good set of tools). Aim to group each view by domain and hide unnecessary internal views.</p>\n<p>Since you’re building views, you can also bake your metric definitions into them, for example a <code>net_revenue</code> column that already excludes refunds, so the agent uses the correct calculation and inherits your definition instead of re-deriving it.</p>\n<p>When using BigQuery/Redshift/Snowflake, declaring primary, foreign and unique keys (even if unenforced) goes a long way later on to help agents build out complex multi-join queries, and spend less time on discovery.</p>\n<h2 id=\"2-comments-on-views\">2. Comments on views</h2>\n<p><strong>Use view comments to help agents discover the right table for their task.</strong> A good view comment should help the agent answer:</p>\n<ul>\n<li>When should I use this table</li>\n<li>What does the table contain</li>\n<li>What is the granularity of the data</li>\n<li>Business synonyms that real users may actually use</li>\n<li>Relationships to other tables in the schema</li>\n<li>Any caveats around the scope/lifecycle e.g. “current vs historical, excludes cancelled, soft-deletes and deprecated”</li>\n</ul>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#D73A49\">COMMENT ON VIEW </span><span style=\"color:#005CC5\">finance</span><span style=\"color:#24292E\">.</span><span style=\"color:#005CC5\">orders</span><span style=\"color:#D73A49\"> IS</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">  'One row per customer order (aka sales, purchases). Current orders only,</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">   excludes cancelled (see finance.orders_all). Joins to finance.customers</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">   on customer_id. Filter on status; time grain is created_at.'</span><span style=\"color:#24292E\">;</span></span></code></pre>\n<p>Comment the columns that matter, not every column. A blanket, low-value description on all 60 columns adds noise the agent has to wade through. Depth on the filter, join and date columns beats coverage.</p>\n<h2 id=\"3-column-names\">3. Column names</h2>\n<p><strong>Keep these legible, as if one of your business users were using them directly.</strong> They should implicitly carry a lot of meaning already:</p>\n<ul>\n<li>Full words over abbreviations e.g. <code>shipping_address</code> over <code>shp_adr</code></li>\n<li>Consistent naming conventions: use <code>snake_case</code>, as mixed-case identifiers force quoting and will case-fold when unquoted, which leads to a lot of agent errors down the line.</li>\n<li>Suffixes give context e.g. <code>_id</code>, <code>_at</code>, <code>_date</code>, <code>is_*</code> (for bools), <code>_pct</code>, <code>_ratio</code></li>\n<li>Add units and scale in the name when ambiguous e.g. <code>amount_cents</code>, <code>weight_kg</code>, <code>duration_seconds</code></li>\n<li>Consistent key naming for table joins e.g. <code>customer_id</code>, <code>account_id</code> etc.</li>\n</ul>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#6A737D\">-- Hard for an agent to read</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TABLE</span><span style=\"color:#6F42C1\"> ord</span><span style=\"color:#24292E\"> (</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">  oid</span><span style=\"color:#D73A49\">   int</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  cust  </span><span style=\"color:#D73A49\">int</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  amt   </span><span style=\"color:#D73A49\">numeric</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  st    </span><span style=\"color:#D73A49\">text</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  dt    </span><span style=\"color:#D73A49\">timestamp</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">-- Self-describing: the name carries the meaning</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TABLE</span><span style=\"color:#6F42C1\"> orders</span><span style=\"color:#24292E\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  id            </span><span style=\"color:#D73A49\">bigint</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  customer_id   </span><span style=\"color:#D73A49\">bigint</span><span style=\"color:#24292E\">,       </span><span style=\"color:#6A737D\">-- FK -> customers.id</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  total_cents   </span><span style=\"color:#D73A49\">integer</span><span style=\"color:#24292E\">,      </span><span style=\"color:#6A737D\">-- minor units</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">  status</span><span style=\"color:#D73A49\">        text</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  created_at    </span><span style=\"color:#D73A49\">timestamptz</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">);</span></span></code></pre>\n<h2 id=\"4-comments-on-columns\">4. Comments on columns</h2>\n<p><strong>Column comments help agents use the various columns in the table correctly.</strong> Depending on the column type, these should typically provide the agent with:</p>\n<ul>\n<li>Column meanings</li>\n<li>Metric definitions</li>\n<li>Additive properties and aggregation caveats for numeric types</li>\n<li>Units and formatting</li>\n<li>Code → label mapping hints</li>\n<li>Join key → targets on other tables</li>\n</ul>\n<h2 id=\"5-native-enums-on-categorical-columns\">5. Native enums on categorical columns</h2>\n<p><strong>For categorical columns with a predictable set of values, expose that set so the agent writes the literal verbatim with the right casing.</strong> Agents will often guess “Cancelled” vs “cancelled” and return a wrong answer to the user when they get the value wrong. Upfront information about category values also means the agent avoids extra queries to discover the possible values each time.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#6A737D\">-- Native enum: the allowed values become metadata the agent can read</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TYPE</span><span style=\"color:#6F42C1\"> order_status</span><span style=\"color:#D73A49\"> AS</span><span style=\"color:#24292E\"> ENUM (</span><span style=\"color:#032F62\">'pending'</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">'paid'</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">'shipped'</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">'cancelled'</span><span style=\"color:#24292E\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TABLE</span><span style=\"color:#6F42C1\"> orders</span><span style=\"color:#24292E\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  id      </span><span style=\"color:#D73A49\">bigint</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">  status</span><span style=\"color:#24292E\">  order_status   </span><span style=\"color:#6A737D\">-- not `text`</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">);</span></span></code></pre>\n<p>Native <code>ENUM</code> types exist on Postgres, ClickHouse, DuckDB and MySQL. BigQuery, Redshift and Snowflake have no enum type, so use the fallback ladder there: a <code>CHECK</code> constraint where supported → the allowed values written into the column comment → a lookup/dimension table joined by a foreign key. The lookup table is also the better choice anywhere the value set churns, since a native <code>ENUM</code> is rigid (adding a value is a DDL change) and a lookup row can carry a label and description per value.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TABLE</span><span style=\"color:#6F42C1\"> order_statuses</span><span style=\"color:#24292E\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  code         </span><span style=\"color:#D73A49\">text</span><span style=\"color:#D73A49\"> PRIMARY KEY</span><span style=\"color:#24292E\">,   </span><span style=\"color:#6A737D\">-- 'pending', 'paid', ...</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  label        </span><span style=\"color:#D73A49\">text</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">  description</span><span style=\"color:#D73A49\">  text</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">);</span></span></code></pre>\n<h2 id=\"6-leverage-the-analytical-warehouses-physical-keys-as-semantic-hints\">6. Leverage the analytical warehouse’s physical keys as semantic hints</h2>\n<p>Analytical warehouses like Redshift, ClickHouse and BigQuery are columnar engines usually serving denormalized models, built for big scans rather than row-level integrity. ClickHouse in particular has no foreign keys at all, and even on BigQuery and Redshift any keys you declare are unenforced.</p>\n<p><strong>But these engines come with their own set of physical keys, chosen for performance, that double as the semantic hints a PK/FK would have given you in Postgres:</strong></p>\n<ul>\n<li><strong>Distribution key</strong> (Redshift) → the column data is co-located on, usually the natural join key (only when the table sets <code>DISTSTYLE KEY</code>; the default <code>AUTO</code> may pick none)</li>\n<li><strong>Sort key / ORDER BY / partition / clustering key</strong> → the columns that filter cheaply, and typically the primary time grain</li>\n<li><strong>Primary key</strong> (ClickHouse) → the grain (it’s a sparse-index/sort hint, not a uniqueness constraint)</li>\n</ul>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#6A737D\">-- ClickHouse: the ORDER BY / PARTITION BY are the grain + cheap filters</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TABLE</span><span style=\"color:#6F42C1\"> events</span><span style=\"color:#24292E\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  event_date  </span><span style=\"color:#D73A49\">Date</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  user_id     UInt64,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  event_type  Enum8(</span><span style=\"color:#032F62\">'view'</span><span style=\"color:#D73A49\"> =</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">'click'</span><span style=\"color:#D73A49\"> =</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">'purchase'</span><span style=\"color:#D73A49\"> =</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#24292E\">),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  amount      </span><span style=\"color:#D73A49\">Decimal</span><span style=\"color:#24292E\">(</span><span style=\"color:#005CC5\">10</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">2</span><span style=\"color:#24292E\">)</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">) ENGINE </span><span style=\"color:#D73A49\">=</span><span style=\"color:#24292E\"> MergeTree</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">PARTITION</span><span style=\"color:#D73A49\"> BY</span><span style=\"color:#24292E\"> toYYYYMM(event_date)   </span><span style=\"color:#6A737D\">-- time grain + cheapest filter</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">ORDER BY</span><span style=\"color:#24292E\"> (user_id, event_date);     </span><span style=\"color:#6A737D\">-- grain + the join/filter columns</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">-- Redshift: distkey = the join column, sortkey = the cheap range filter</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">CREATE</span><span style=\"color:#D73A49\"> TABLE</span><span style=\"color:#6F42C1\"> events</span><span style=\"color:#24292E\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  event_date  </span><span style=\"color:#D73A49\">date</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  user_id     </span><span style=\"color:#D73A49\">bigint</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  amount      </span><span style=\"color:#D73A49\">numeric</span><span style=\"color:#24292E\">(</span><span style=\"color:#005CC5\">10</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">2</span><span style=\"color:#24292E\">)</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">)</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">DISTSTYLE </span><span style=\"color:#D73A49\">KEY</span><span style=\"color:#24292E\"> DISTKEY (user_id)</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">SORTKEY (event_date);</span></span></code></pre>\n<p>Read together, these let an agent infer a table’s grain, the cheapest columns to filter on, and, from the distribution key specifically, a likely join column. Note the split: the distribution key is the join-shaped hint, while the sort and partition keys are grain and cheap-filter hints, not join paths.</p>\n<p>Two caveats: they’re heuristics, not guarantees (a distribution key is usually the join key, but not always), so where an engine lets you declare an unenforced PK/FK, do that too; and choose these keys deliberately, since they’re now doing double duty as both a performance decision and documentation an agent reads for free.</p>\n<h2 id=\"7-let-the-engines-own-statistics-describe-the-data\">7. Let the engine’s own statistics describe the data</h2>\n<p><strong>Your warehouse already computes a lot about your data. Surface it instead of making the agent go and find out.</strong></p>\n<p>At the column level, Postgres’ <code>pg_stats</code> and DuckDB’s <code>SUMMARIZE</code> unlock helpful statistics e.g. <code>n_distinct</code>, <code>most_common_vals</code>, <code>histogram_bounds</code>, min/max, null %. These let an agent interpret a column before it queries: the real range of a date column before it writes a <code>BETWEEN</code>, or whether a <code>status</code> column is skewed 95% to one value. (Availability varies: Snowflake and BigQuery expose no user-readable column-stats view, so lean on comments and enums there.)</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#24292E\">SUMMARIZE orders;</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">-- column_name | min | max | approx_unique | null_percentage | ...  (per column)</span></span></code></pre>\n<p>At the table level, row counts and byte size tell the agent what volume it is working with so it can adjust its SQL accordingly. This also serves as fact-vs-dimension routing, so an agent can tell a 50M-row fact table from a 12-row lookup and pick the right driving table in a join.</p>\n<p>A word of caution in multi-tenant setups: on a table with row-level security, Postgres hides <code>pg_stats</code> from a non-privileged reader (you get nothing), while the row counts here (<code>reltuples</code>, <code>n_live_tup</code>) are RLS-blind and report the whole-table volume regardless of policy. Treat both carefully when tenants share a table.</p>\n<h2 id=\"what-this-wont-do\">What this won’t do</h2>\n<p>This gets you discovery and legibility, not a full metrics platform. The main thing you give up versus a dedicated tool like Cube is caching and pre-aggregation: an agent that fans out into dozens of follow-up questions hits your warehouse, and your bill, each time. If controlling cost at high query volume is a hard requirement, that’s where a semantic-layer product still earns its place.</p>\n<p>One last thing to keep in mind if your agents are external or per-tenant: metadata itself can leak. Table names, comments and RLS-blind row counts are readable through the catalog, so findable is not the same as safe to expose. Scope what each agent’s role can read.</p>\n<h2 id=\"wrapping-up\">Wrapping up</h2>\n<p>By applying some or all of the above tips depending on your current warehouse setup, you can create a remarkably effective foundation for agent queries without adopting a completely new tool or framework.</p>\n<p>However, this isn’t all there is to it. Agents will also need the right tools to explore and query your new semantic layer effectively.</p>\n<p>Our next article will cover what a good agent toolset looks like, whether you are handrolling your own agents or creating a data MCP for your users.</p>\n",
      "date_published": "2026-07-16T00:00:00.000Z",
      "date_modified": "2026-07-16T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/mcp-charts",
      "url": "https://bonnard.dev/blog/mcp-charts",
      "title": "MCP Charts: Add Interactive Charts to Your MCP Server",
      "summary": "Give your AI agent a visualize tool: interactive charts inside any MCP Apps client from real query data. Works with any MCP server via @bonnard/mcp-charts.",
      "content_html": "<p>Your MCP server returns data. The agent reads it back as a wall of numbers, or it tries to draw a chart in HTML and gets it wrong. The visual, the part the user actually wanted, gets left to the model to improvise.</p>\n<p>MCP charts fix that. You give your MCP server one <code>visualize</code> tool: the agent calls it with a query, your database returns the rows, and an interactive chart renders right in Claude or ChatGPT. You write no frontend code. That is what <a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>@bonnard/mcp-charts</code></a> does, handling the tool, the chart, the cross-host widget, and the theming.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { McpServer } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@modelcontextprotocol/sdk/server/mcp.js\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { postgresRunSql } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts/postgres\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#D73A49\">const</span><span style=\"color:#005CC5\"> server</span><span style=\"color:#D73A49\"> =</span><span style=\"color:#D73A49\"> new</span><span style=\"color:#6F42C1\"> McpServer</span><span style=\"color:#24292E\">({ name: </span><span style=\"color:#032F62\">\"acme\"</span><span style=\"color:#24292E\">, version: </span><span style=\"color:#032F62\">\"1.0.0\"</span><span style=\"color:#24292E\"> });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  runSql: </span><span style=\"color:#6F42C1\">postgresRunSql</span><span style=\"color:#24292E\">(pool), </span><span style=\"color:#6A737D\">// maps pg column types to chart field kinds</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<h2 id=\"how-mcp-charts-work\">How MCP charts work</h2>\n<p>A chart in an MCP client is not an image. It is an interactive widget the host renders from a spec the tool returns.</p>\n<ol>\n<li>The agent calls <code>visualize</code> with a query (SQL, a semantic query, or chart params).</li>\n<li>Your <code>runSql</code> callback runs it against your warehouse or ORM and returns rows.</li>\n<li>Bonnard infers the chart encoding from the typed result and returns a sandboxed <code>ui://</code> widget.</li>\n<li>Claude or ChatGPT renders it. One widget, both hosts.</li>\n</ol>\n<p>You connect the data. Bonnard never sees your database, only the rows your callback returns. Native adapters ship for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<h2 id=\"which-charts-it-renders\">Which charts it renders</h2>\n<p><code>@bonnard/mcp-charts</code> renders line, bar, area, pie, scatter, funnel, waterfall, and table, with bar variants for stacked, grouped, horizontal, and 100% stacked. Set <code>chartType</code> or let the resolver pick from the shape of the data: a time dimension and a measure becomes a line; a category and a measure becomes a bar.</p>\n<h2 id=\"why-agents-need-a-real-chart-tool\">Why agents need a real chart tool</h2>\n<p>Without one, an agent that is asked for “revenue by region as a chart” has two bad options. It dumps a wall of numbers, or it generates HTML and hopes the host renders it. Both put the most important part of the answer, the visual, in the model’s hands to improvise.</p>\n<p>A dedicated tool fixes that:</p>\n<ul>\n<li><strong>Deterministic rendering.</strong> The chart comes from your query result, not from tokens the model invents. Same data, same chart, every time.</li>\n<li><strong>No frontend work.</strong> One widget renders across every MCP host that supports apps. You do not maintain per-client rendering code.</li>\n<li><strong>Built for the agent.</strong> The tool returns a compact summary the model can read, caps oversized results, and gives instructive errors that tell the agent what to fix. The agent uses it correctly because the tool is shaped for how it calls things.</li>\n</ul>\n<h2 id=\"render-charts-from-sql-in-an-agent\">Render charts from SQL in an agent</h2>\n<p>The common pattern is two tools: one for the agent to learn your schema, one to chart the result.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// the agent discovers tables, writes SQL, then charts the rows</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">server.</span><span style=\"color:#6F42C1\">registerTool</span><span style=\"color:#24292E\">(</span><span style=\"color:#032F62\">\"explore_schema\"</span><span style=\"color:#24292E\">, { </span><span style=\"color:#6A737D\">/* list tables + columns */</span><span style=\"color:#24292E\"> }, listSchema);</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>Point any MCP client at the server. Ask “chart monthly revenue by plan.” The agent writes the query, runs it through your callback, and the bar chart appears in the chat.</p>\n<h2 id=\"mcp-chart-server-vs-antv-mcp-server-chart-and-others\">MCP chart server vs AntV mcp-server-chart and others</h2>\n<p>Most MCP chart servers take chart parameters from the agent and render a generic image. Bonnard takes your query result and infers the chart, so the visual is grounded in real data, not in parameters the model made up.</p>\n<table>\n<thead>\n<tr>\n<th>Server</th>\n<th>Input</th>\n<th>Output</th>\n<th>Grounded in your data</th>\n<th>Agent experience</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>AntV <code>mcp-server-chart</code></td>\n<td>chart params</td>\n<td>static image</td>\n<td>No</td>\n<td>Basic</td>\n</tr>\n<tr>\n<td>Highcharts MCP</td>\n<td>chart params</td>\n<td>static image</td>\n<td>No</td>\n<td>Basic</td>\n</tr>\n<tr>\n<td>Mermaid Chart MCP</td>\n<td>diagram spec</td>\n<td>diagram</td>\n<td>No</td>\n<td>Basic</td>\n</tr>\n<tr>\n<td><strong><code>@bonnard/mcp-charts</code></strong></td>\n<td>your query result</td>\n<td>interactive widget</td>\n<td>Yes</td>\n<td>Summaries, row caps, instructive errors</td>\n</tr>\n</tbody>\n</table>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"how-do-i-add-charts-to-an-mcp-server\">How do I add charts to an MCP server?</h3>\n<p>Install <code>@bonnard/mcp-charts</code>, call <code>addCharts(server, { runSql })</code>, and pass a callback that runs SQL against your data. That registers a <code>visualize</code> tool. The agent calls it; the chart renders in the client. No UI code.</p>\n<h3 id=\"are-mcp-charts-worth-it\">Are MCP charts worth it?</h3>\n<p>If your MCP server returns data an agent will summarize, yes. A chart in the conversation is faster to read than a table, and a dedicated tool keeps the visual deterministic instead of letting the model draw HTML it might get wrong.</p>\n<h3 id=\"which-mcp-clients-show-charts\">Which MCP clients show charts?</h3>\n<p>Hosts that support MCP apps render the interactive widget: Claude, ChatGPT, Cursor, and GitHub/Microsoft 365 Copilot today, with more clients adding support.</p>\n<h3 id=\"what-is-the-best-mcp-chart-library\">What is the best MCP chart library?</h3>\n<p>Pick on where the chart comes from. If you want the agent to pass chart specs, a generic server like AntV works. If you want charts grounded in your own query results, with no frontend code and agent-tuned behavior, that is what <code>@bonnard/mcp-charts</code> is built for.</p>\n<h3 id=\"does-bonnard-see-my-data\">Does Bonnard see my data?</h3>\n<p>No. You provide the <code>runSql</code> callback. Bonnard renders the rows it returns and never connects to your database.</p>\n<p>Add it to your server in a few lines: <a href=\"https://github.com/bonnard-data/mcp-charts\"><code>@bonnard/mcp-charts</code></a>. For the design behind an agent-friendly chart tool, see <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">How Bonnard Builds Agent-Friendly MCPs</a>.</p>\n",
      "date_published": "2026-06-25T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/how-bonnard-builds-agent-friendly-mcps",
      "url": "https://bonnard.dev/blog/how-bonnard-builds-agent-friendly-mcps",
      "title": "How Bonnard Builds Agent-Friendly MCPs",
      "summary": "Exposing data over MCP is easy. Designing a tool an agent uses well is hard. Discovery-first tools, compact responses, instructive errors, determinism.",
      "content_html": "<p>Exposing your data over MCP is the easy part. Designing a tool an agent uses <em>well</em> is the hard part. An agent can only use a tool it can read, so the work is shaping the tool for how the model calls it, not just for the human looking at the result. These are the techniques behind <a href=\"/blog/mcp-charts\"><code>@bonnard/mcp-charts</code></a> and the <code>visualize</code> tool.</p>\n<h2 id=\"discovery-first-so-the-agent-stops-guessing\">Discovery-first, so the agent stops guessing</h2>\n<p>An agent that guesses your schema writes wrong queries. So the first tool the agent meets is a discovery tool. <code>addCharts</code> takes a <code>discovery: { toolName }</code> option naming your own schema tool, and <code>visualize</code> then opens its description by telling the agent to call that tool first and learn your tables and columns before it writes SQL. The chart options travel in <code>visualize</code>’s input schema, each field described where the agent reads it. The agent reads, then acts.</p>\n<h2 id=\"a-small-set-of-purpose-built-tools\">A small set of purpose-built tools</h2>\n<p>The temptation is one tool per metric, or a single tool that takes arbitrary SQL and hopes. Both fail: too many tools blow the agent’s attention budget; one firehose tool gives it no guardrails. Bonnard ships a small set, discover, query, visualize, each with a narrow, obvious job. The agent picks the right one because there are few of them and each does one thing.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// a small, purpose-built set, not one tool per metric</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">server.</span><span style=\"color:#6F42C1\">registerTool</span><span style=\"color:#24292E\">(</span><span style=\"color:#032F62\">\"explore_schema\"</span><span style=\"color:#24292E\">, { </span><span style=\"color:#6A737D\">/* list tables + columns */</span><span style=\"color:#24292E\"> }, listSchema);</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql, discovery: { toolName: </span><span style=\"color:#032F62\">\"explore_schema\"</span><span style=\"color:#24292E\"> } }); </span><span style=\"color:#6A737D\">// registers visualize</span></span></code></pre>\n<h2 id=\"compact-honest-responses\">Compact, honest responses</h2>\n<p>A tool that returns 10,000 raw rows poisons the context window and the agent’s next decision. Bonnard’s responses are sized for a model to read:</p>\n<ul>\n<li><strong>Row caps with a completeness flag.</strong> Results are capped and tagged <code>partial</code> or <code>complete</code>, so the agent knows whether it is looking at everything.</li>\n<li><strong>Partial-result warnings.</strong> When results are capped, the response says so and tells the agent not to sum or average the visible rows, use a measure instead.</li>\n<li><strong>Summaries over dumps.</strong> The chart comes back with a compact text summary the model can reason over, not just an image it cannot read.</li>\n</ul>\n<h2 id=\"errors-that-guide-the-next-action\">Errors that guide the next action</h2>\n<p>A bare “error: invalid column” stalls an agent. Bonnard’s errors carry a fix. A bad field name returns a hint to check names with <code>explore_schema</code>; a SQL error returns targeted guidance; successful calls include a <code>next_step</code> pointing at the right follow-up tool. The agent self-corrects instead of looping.</p>\n<h2 id=\"determinism-over-improvisation\">Determinism over improvisation</h2>\n<p>The most important move is pushing deterministic work out of the model and into the code. The agent picks <em>what</em> to show (a chart type, a query). Bonnard decides <em>how</em>: it infers axes from the typed result, auto-detects currency and percentage formatting, fills missing time buckets, and labels null dimensions. The smaller the agent’s decision surface, the less there is to hallucinate.</p>\n<h2 id=\"tool-descriptions-that-double-as-instructions\">Tool descriptions that double as instructions</h2>\n<p>To an agent, the tool description <em>is</em> the documentation. Bonnard’s tools use a typed schema with a description on every field, plus a read-me tool that returns usage examples on demand. The agent knows how and when to call <code>visualize</code> because the schema tells it, not because you wrote a prompt.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-makes-an-mcp-tool-agent-friendly\">What makes an MCP tool “agent-friendly”?</h3>\n<p>It is shaped for how a model calls tools: discovery-first so the agent learns before it acts, a small set of narrow tools, compact responses with completeness flags, errors that suggest the fix, and a typed, self-describing schema. The agent uses it correctly without hand-holding.</p>\n<h3 id=\"why-not-just-let-the-agent-write-sql-and-draw-the-chart\">Why not just let the agent write SQL and draw the chart?</h3>\n<p>Text-to-SQL against raw tables is wrong often enough that you cannot put it in front of a customer, and an agent drawing its own chart HTML is non-deterministic. A governed query path plus a dedicated chart tool returns consistent, trustworthy visuals.</p>\n<h3 id=\"does-bonnard-support-raw-sql-at-all\">Does Bonnard support raw SQL at all?</h3>\n<p>Yes, as a narrow, audited path, not a bypass around governance. You control what runs via your <code>runSql</code> callback.</p>\n<p>See the product this is built on: <a href=\"/blog/mcp-charts\">MCP Charts</a>.</p>\n",
      "date_published": "2026-06-16T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/ai-data-analysis",
      "url": "https://bonnard.dev/blog/ai-data-analysis",
      "title": "AI Data Analysis: Why Governed Metrics Beat Raw SQL",
      "summary": "A guide to AI data analysis for SaaS: the tool categories, the accuracy problem, and the MCP-native way to chart query results inside an agent.",
      "content_html": "<p>AI data analysis tools are everywhere. Upload a spreadsheet to Julius AI and get a chart. Ask ChatGPT’s Data Analyst to find trends. Connect Databricks AI/BI to your warehouse and let it generate queries. The pitch: anyone can analyze data without writing SQL.</p>\n<p>The pitch works for one-off exploration. It breaks when you need trustworthy numbers at scale: consistent results across tools, governed access per customer, and an audit trail for every answer. That’s where most AI analytics tools stop and where a semantic layer starts.</p>\n<h2 id=\"what-is-ai-data-analysis\">What is AI data analysis?</h2>\n<p>AI data analysis uses large language models and machine learning to automate parts of the data analysis workflow: generating queries, identifying patterns, creating visualizations, and summarizing findings in natural language. Instead of writing SQL by hand, you describe what you want and the AI produces it.</p>\n<p>The tools fall into three categories:</p>\n<p><strong>Spreadsheet AI.</strong> Upload a CSV and ask questions. Julius AI, Formula Bot, ChatGPT Data Analyst. Good for ad-hoc exploration of small datasets. No governance, no multi-tenancy, no audit trail. Your data leaves your infrastructure.</p>\n<p><strong>BI tool AI.</strong> Built into an existing BI platform. Tableau’s AI analytics, Power BI Copilot, Domo AI, ThoughtSpot’s natural language search. Better than spreadsheet tools because they query your warehouse. Still limited by the BI tool’s ecosystem and licensing model.</p>\n<p><strong>Text-to-SQL agents.</strong> An LLM generates SQL against your warehouse schema. DataGPT, custom GPT agents, Databricks AI/BI. Most flexible, most dangerous. The agent writes arbitrary SQL. No guardrails on what it queries or how it calculates metrics.</p>\n<p>All three share the same fundamental problem: the AI interprets your data on every query. There’s no shared definition of what “revenue” means. Different tools, different phrasings, different numbers.</p>\n<h2 id=\"the-accuracy-problem-nobody-talks-about\">The accuracy problem nobody talks about</h2>\n<p>AI data analysis demos are impressive. The tool generates a chart from a natural language question in seconds. But run the same question through two different tools and compare the numbers. They don’t match.</p>\n<p><strong>Why?</strong> Because each tool independently interprets your schema. “Revenue” might be <code>SUM(amount)</code> in one tool and <code>SUM(total_price) WHERE status = 'completed'</code> in another. Both are plausible. Neither knows that your finance team excludes refunds and trial conversions.</p>\n<p>This isn’t a model quality issue. GPT-4, Claude, and Gemini all exhibit this behavior. The model does what it’s designed to do: generate plausible SQL from schema context. But “plausible” and “correct” are different things.</p>\n<p>The accuracy problem compounds at scale:</p>\n<ul>\n<li><strong>One analyst, one question, one tool.</strong> Accuracy is “good enough.” You eyeball the result and know if it’s in the right ballpark.</li>\n<li><strong>Multiple analysts, same question, different tools.</strong> The numbers diverge. Debates about “which dashboard is right” start.</li>\n<li><strong>Customers querying their own data.</strong> You can’t tell customers “the number is approximately right.” They expect precision. One wrong number and trust is gone.</li>\n<li><strong>AI agents querying autonomously.</strong> No human in the loop to sanity-check. Wrong numbers propagate through workflows without anyone noticing.</li>\n</ul>\n<p><a href=\"/glossary/text-to-sql\">Text-to-SQL</a> gives you speed. It doesn’t give you trust. For production analytics, you need both.</p>\n<h2 id=\"what-makes-ai-analytics-production-grade\">What makes AI analytics production-grade?</h2>\n<p>The gap between a demo and a production system is five things. Most AI data analysis tools have one or two. None of the spreadsheet or text-to-SQL tools have all five.</p>\n<h3 id=\"1-governed-metric-definitions\">1. Governed metric definitions</h3>\n<p>Every metric has one definition. “Revenue” is <code>SUM(amount) WHERE status != 'refunded' AND type != 'trial'</code>. This definition is fixed, versioned in Git, and referenced by every consumer. The AI doesn’t interpret what revenue means. It queries the governed definition.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' AND type != 'trial' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"Total revenue excluding refunds and trials\"</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">average_order_value</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">amount</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">avg</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span></code></pre>\n<p>This is a <a href=\"/glossary/semantic-layer\">semantic layer</a>: a metadata layer between your warehouse and every consumer. The AI agent calls <code>explore_schema</code> to discover available metrics, then <code>query</code> to fetch governed data. It never generates SQL from scratch.</p>\n<h3 id=\"2-consistency-across-every-surface\">2. Consistency across every surface</h3>\n<p>The same metric definition serves dashboards, React components in your product, REST APIs, AI agents, and markdown reports. Ask for “revenue” from any surface and you get the same number. Not because the AI happened to generate the same SQL, but because the definition is fixed.</p>\n<p>This matters for AI specifically because AI tools generate different SQL on every call. Even the same tool, same question, different session can produce different queries. Governed definitions eliminate this variance.</p>\n<h3 id=\"3-multi-tenant-access-control\">3. Multi-tenant access control</h3>\n<p>For B2B products, Customer A’s AI agent should never see Customer B’s data. This can’t be a prompt instruction (“only query data for customer A”). It needs to be structural: enforced on every query regardless of what the AI does.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">    security_context</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">tenant_filter</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"{SECURITY_CONTEXT.tenant_id} = customer_id\"</span></span></code></pre>\n<p>Every query through a tenant’s key automatically includes this filter. The AI agent can’t bypass it. See <a href=\"/glossary/rbac\">RBAC</a> for more on how this works.</p>\n<h3 id=\"4-performance-at-ai-scale\">4. Performance at AI scale</h3>\n<p>AI agents make more queries than humans. A human asks one question. An agent might make 10 queries to answer it: exploring the schema, trying different dimensions, following up on anomalies. Multiply by hundreds of customers’ agents querying simultaneously.</p>\n<p><a href=\"/glossary/pre-aggregation\">Pre-aggregation</a> caching pre-computes common rollups. Hot queries return in single-digit milliseconds. Without it, AI-scale query volumes overwhelm most warehouses.</p>\n<h3 id=\"5-audit-trail\">5. Audit trail</h3>\n<p>When the number is wrong, you need to trace it. With a semantic layer, every result traces back to a versioned metric definition in Git. You can point to the exact commit that defined the calculation, see when it changed, and who approved the change. With raw text-to-SQL, the trail is “an LLM generated some SQL” with no reproducibility.</p>\n<h2 id=\"ai-data-analysis-tools-compared\">AI data analysis tools compared</h2>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Approach</th>\n<th>Governed metrics</th>\n<th>Multi-tenant</th>\n<th>Audit trail</th>\n<th>Best for</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Julius AI</strong></td>\n<td>Spreadsheet upload + LLM</td>\n<td>No</td>\n<td>No</td>\n<td>No</td>\n<td>Ad-hoc exploration of CSVs</td>\n</tr>\n<tr>\n<td><strong>ChatGPT Data Analyst</strong></td>\n<td>File upload + code interpreter</td>\n<td>No</td>\n<td>No</td>\n<td>No</td>\n<td>One-off analysis, prototyping</td>\n</tr>\n<tr>\n<td><strong>Databricks AI/BI</strong></td>\n<td>Text-to-SQL + Unity Catalog</td>\n<td>Partial (table-level)</td>\n<td>Partial</td>\n<td>Partial</td>\n<td>Teams already on Databricks</td>\n</tr>\n<tr>\n<td><strong>Power BI Copilot</strong></td>\n<td>Natural language in Power BI</td>\n<td>DAX measures (locked to PBI)</td>\n<td>Complex setup</td>\n<td>Within PBI</td>\n<td>Microsoft ecosystem</td>\n</tr>\n<tr>\n<td><strong>Tableau AI</strong></td>\n<td>Natural language in Tableau</td>\n<td>Tableau semantic model</td>\n<td>Enterprise</td>\n<td>Within Tableau</td>\n<td>Salesforce ecosystem</td>\n</tr>\n<tr>\n<td><strong>ThoughtSpot</strong></td>\n<td>Natural language BI</td>\n<td>Proprietary semantic layer</td>\n<td>Enterprise</td>\n<td>Yes</td>\n<td>Enterprise AI-powered BI</td>\n</tr>\n<tr>\n<td><strong>DataGPT</strong></td>\n<td>Conversational analytics agent</td>\n<td>No</td>\n<td>No</td>\n<td>No</td>\n<td>Autonomous data analysis</td>\n</tr>\n<tr>\n<td><strong>Domo AI</strong></td>\n<td>BI platform with AI features</td>\n<td>Within Domo</td>\n<td>Within Domo</td>\n<td>Within Domo</td>\n<td>End-to-end data platform</td>\n</tr>\n<tr>\n<td><strong>Charts in agent (Bonnard)</strong></td>\n<td><code>visualize</code> tool on your MCP server</td>\n<td>N/A (your <code>runSql</code>)</td>\n<td>N/A (your DB)</td>\n<td>N/A</td>\n<td>Interactive charts inside Claude or ChatGPT</td>\n</tr>\n</tbody>\n</table>\n<p>The tools in the top half are AI-first but governance-second. The ones in the middle (enterprise BI) have governance but lock it to their ecosystem. The bottom row is a different kind of option: <code>@bonnard/mcp-charts</code> does not generate SQL or define metrics. It renders interactive charts from the rows your <code>runSql</code> returns, inside the agent.</p>\n<h2 id=\"when-each-approach-makes-sense\">When each approach makes sense</h2>\n<p><strong>Use spreadsheet AI (Julius, ChatGPT)</strong> when:</p>\n<ul>\n<li>You’re exploring a dataset for the first time</li>\n<li>Nobody else needs to reproduce the result</li>\n<li>The data isn’t sensitive</li>\n<li>You want speed over accuracy</li>\n</ul>\n<p><strong>Use BI tool AI (Power BI Copilot, Tableau AI)</strong> when:</p>\n<ul>\n<li>You’re already in that BI ecosystem</li>\n<li>Internal teams are the primary consumer</li>\n<li>You don’t need to serve AI agents or embed in products</li>\n<li>Enterprise licensing fits your budget</li>\n</ul>\n<p><strong>Use a semantic layer</strong> when:</p>\n<ul>\n<li>Multiple consumers need the same metrics (dashboards + API + agents)</li>\n<li>Customers see the results (B2B <a href=\"/glossary/embedded-analytics\">embedded analytics</a>)</li>\n<li>AI agents query data autonomously</li>\n<li>You need auditability (which definition produced this number?)</li>\n<li>Metrics must be consistent across every tool and surface</li>\n</ul>\n<h2 id=\"getting-the-chart-back-charts-inside-the-agent\">Getting the chart back: charts inside the agent</h2>\n<p>Governing the numbers is one half of AI data analysis. The other half is what the customer actually sees. When someone asks a question in Claude or ChatGPT, they often want a chart, not a table of rows. That’s a separate surface from the governance layer, and it’s where <code>@bonnard/mcp-charts</code> fits.</p>\n<p><code>@bonnard/mcp-charts</code> is not a semantic layer, a BI tool, or a text-to-SQL agent. It is a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results, inside the agent.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool. The flow:</p>\n<ol>\n<li>The agent calls <code>visualize</code> with a query.</li>\n<li>Your <code>runSql</code> returns rows.</li>\n<li>Bonnard infers the chart from the typed result.</li>\n<li>It renders an interactive <code>ui://</code> widget in Claude or ChatGPT.</li>\n</ol>\n<p>Chart types: line, bar, area, pie, scatter, funnel, waterfall, and table. Axes, formatting, and gap-fill are determined automatically, so the same typed result produces the same chart. Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>. Bonnard never touches your database.</p>\n<p>The tool is built for the agent: compact summaries, row caps with completeness flags, a typed schema, and instructive errors with a <code>next_step</code>. For the design principles, see <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">how Bonnard builds agent-friendly MCPs</a>.</p>\n<h2 id=\"the-shift-from-ai-generated-sql-to-ai-queried-metrics\">The shift from AI-generated SQL to AI-queried metrics</h2>\n<p>The first generation of AI analytics gave agents raw warehouse access and hoped for the best. It worked in demos. It failed in production because the agent interprets business logic on every query.</p>\n<p>The next generation separates the concerns. Data teams define governed metrics in a <a href=\"/glossary/semantic-layer\">semantic layer</a>. AI agents query those definitions instead of raw tables. The data team’s expertise is encoded in the schema. The AI’s role shifts from “guess what revenue means” to “pick the right governed metric and present the result.”</p>\n<p>This is a better division of labor. The data team defines what’s true. The AI handles the interface. Trustworthy numbers with a natural language frontend.</p>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>If you want charts rendered inside an AI agent from your own query results, install the package and register the tool on your MCP server:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p>That registers the <code>visualize</code> tool. Connect your server in Claude or ChatGPT, and the agent can render charts from your query results. Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<p>Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>. Full walkthrough: <a href=\"/blog/mcp-charts\">MCP charts</a>. For background: <a href=\"/blog/what-is-a-semantic-layer\">What Is a Semantic Layer?</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-ai-data-analysis-1\">What is AI data analysis?</h3>\n<p>AI data analysis uses large language models and machine learning to automate data querying, pattern detection, and insight generation. Instead of writing SQL manually, you ask questions in natural language and the AI produces queries, charts, and summaries. Tools range from spreadsheet analyzers (Julius AI) to enterprise BI platforms with AI features (Power BI Copilot, Tableau AI).</p>\n<h3 id=\"is-ai-data-analysis-accurate\">Is AI data analysis accurate?</h3>\n<p>For ad-hoc exploration, accuracy is “good enough.” For production analytics where customers see the numbers, accuracy varies per query because the AI generates SQL from scratch each time. Two tools generating SQL for “revenue” may produce different numbers because they interpret the calculation differently. A semantic layer solves this by defining each metric once and letting AI query the definition instead of generating its own.</p>\n<h3 id=\"what-is-the-best-ai-tool-for-data-analysis\">What is the best AI tool for data analysis?</h3>\n<p>It depends on the use case. For one-off CSV exploration: Julius AI or ChatGPT Data Analyst. For enterprise BI: Power BI Copilot or Tableau AI. For governed metrics served to AI agents and B2B customers: a semantic layer approach. See the comparison table above.</p>\n<h3 id=\"what-is-the-best-approach-to-ai-data-analysis-for-saas\">What is the best approach to AI data analysis for SaaS?</h3>\n<p>For a SaaS product, two things matter: trustworthy numbers and where the customer sees the result. Govern the numbers with a semantic layer so the AI queries fixed definitions instead of guessing. Then decide the surface. If your customers work in Claude or ChatGPT, <code>@bonnard/mcp-charts</code> adds a <code>visualize</code> tool to your MCP server that renders interactive charts from your query results, inside the agent. Your <code>runSql</code> runs the query, so Bonnard never touches your database. Install with <code>npm install @bonnard/mcp-charts</code>.</p>\n<h3 id=\"how-is-ai-analytics-different-from-traditional-bi\">How is AI analytics different from traditional BI?</h3>\n<p>Traditional BI requires analysts to write queries and build dashboards. AI analytics automates the query generation. The risk: AI-generated queries are inconsistent. Traditional BI with a semantic layer gives you consistent definitions. AI analytics with a semantic layer gives you both: consistent definitions with a natural language interface.</p>\n<h3 id=\"can-ai-replace-data-analysts\">Can AI replace data analysts?</h3>\n<p>No. AI can generate queries and create visualizations faster than humans. It can’t define what metrics should mean, decide which questions to ask, or judge whether an insight is actionable. Data analysts bring domain knowledge and judgment. AI handles the mechanical parts. A semantic layer encodes the analyst’s expertise so AI can query it reliably.</p>\n<h3 id=\"what-is-the-difference-between-ai-analytics-and-text-to-sql\">What is the difference between AI analytics and text-to-SQL?</h3>\n<p><a href=\"/glossary/text-to-sql\">Text-to-SQL</a> is a technique where an LLM generates SQL from natural language. AI analytics is broader: it includes text-to-SQL plus pattern detection, anomaly identification, forecasting, and visualization generation. Both share the same governance gap: the AI interprets your data on every query. A semantic layer addresses this for both.</p>\n<h3 id=\"what-warehouses-work-with-ai-data-analysis\">What warehouses work with AI data analysis?</h3>\n<p>Most AI analytics tools work with common warehouses: <a href=\"/integrations/snowflake\">Snowflake</a>, <a href=\"/integrations/bigquery\">BigQuery</a>, <a href=\"/integrations/databricks\">Databricks</a>, <a href=\"/integrations/postgres\">PostgreSQL</a> (including Supabase, Neon, and RDS), <a href=\"/integrations/redshift\">Redshift</a>, and <a href=\"/integrations/duckdb\">DuckDB</a> (including MotherDuck). The semantic layer generates the appropriate SQL dialect for each warehouse.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/ai-reporting",
      "url": "https://bonnard.dev/blog/ai-reporting",
      "title": "AI Reporting: How to Automate Reports Without Losing Trust",
      "summary": "A guide to AI reporting for SaaS: the tool landscape, the trust problem, and the MCP-native way to render charts from your query results inside an agent.",
      "content_html": "<p>Your data team spends 40% of their time building reports. Weekly revenue summaries. Monthly board decks. Quarterly business reviews. Customer-facing usage reports. Each one requires pulling data, checking the numbers match last month’s methodology, formatting it, and sending it out.</p>\n<p>AI reporting tools promise to automate this. Ask for a report in natural language and get charts, tables, and summaries in seconds. The problem: the AI generates the queries from scratch each time. This month’s revenue calculation might differ from last month’s. The board deck numbers might not match the customer report. Automation without governance creates reports nobody trusts.</p>\n<h2 id=\"what-is-ai-reporting\">What is AI reporting?</h2>\n<p>AI reporting uses large language models to automate parts of the reporting workflow: querying data, generating visualizations, writing narrative summaries, and scheduling delivery. Instead of an analyst manually pulling numbers and building slides, the AI handles the mechanical work.</p>\n<p>The tools range from simple (ChatGPT generating a summary from a CSV) to sophisticated (enterprise BI platforms with AI-powered report builders). What they share: the AI interprets your data on every run. There’s no guarantee that “revenue” means the same thing in this week’s report as it did last week.</p>\n<h2 id=\"why-ai-generated-reports-break-trust\">Why AI-generated reports break trust</h2>\n<h3 id=\"inconsistent-methodology\">Inconsistent methodology</h3>\n<p>The AI generates SQL from your schema context. This week it calculates revenue as <code>SUM(amount)</code>. Next week, with slightly different prompt context, it adds a <code>WHERE status = 'completed'</code> filter. The CEO compares the two reports and asks why revenue dropped 8%. It didn’t drop. The calculation changed. Nobody noticed because the SQL is generated on the fly.</p>\n<h3 id=\"no-audit-trail\">No audit trail</h3>\n<p>When the CFO asks “where did this number come from?”, the answer should be a versioned metric definition, not “an LLM wrote some SQL.” Automated reports need the same auditability as manual ones. If you can’t reproduce a number from a specific report, the report is useless for compliance, board materials, or customer-facing delivery.</p>\n<h3 id=\"metric-drift-across-reports\">Metric drift across reports</h3>\n<p>The weekly ops report, the monthly board deck, and the quarterly customer report all show “revenue.” Without a shared definition, each report calculates it independently. The numbers diverge. Stakeholders compare reports and lose confidence. The data team gets pulled into reconciliation instead of building.</p>\n<h2 id=\"the-governed-approach-to-ai-reporting\">The governed approach to AI reporting</h2>\n<p>The fix isn’t avoiding AI. It’s separating what AI is good at (generating natural language summaries, choosing visualizations, scheduling delivery) from what it’s bad at (defining business metrics).</p>\n<h3 id=\"define-metrics-once\">Define metrics once</h3>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">revenue_metrics</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">analytics.monthly_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' AND type != 'trial' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"Finance-approved revenue (excludes refunds and trials)\"</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">mrr</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">monthly_amount</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"Monthly recurring revenue from active subscriptions\"</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">net_revenue_retention</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"current_mrr / previous_mrr\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">avg</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"NRR: current period MRR / prior period MRR for the same cohort\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">plan</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">plan_name</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">period</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">period_date</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span></code></pre>\n<p>Every report, whether generated by AI or built manually, references these definitions. The methodology is fixed. This month’s “revenue” uses the same calculation as last month’s.</p>\n<h3 id=\"let-ai-handle-presentation-not-calculation\">Let AI handle presentation, not calculation</h3>\n<p>The AI’s job shifts from “calculate revenue” to “present the governed revenue number.” It queries the <a href=\"/glossary/semantic-layer\">semantic layer</a>, gets a trustworthy number, and wraps it in a narrative: “Revenue grew 12% QoQ, driven primarily by Enterprise plan expansion in EMEA.”</p>\n<p>This is a better division of labor. The data team defines what’s true. The AI makes it readable.</p>\n<h3 id=\"charts-in-the-agent-from-your-query-results\">Charts in the agent, from your query results</h3>\n<p>When the report lives inside an AI agent, the customer often wants a chart in the conversation, not only a narrative. That’s a separate surface from the governance layer, and it’s where <code>@bonnard/mcp-charts</code> fits.</p>\n<p><code>@bonnard/mcp-charts</code> is not a reporting tool or a BI platform. It is a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results, inside the agent.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool. The agent calls <code>visualize</code>, your <code>runSql</code> returns rows, Bonnard infers the chart from the typed result, and it renders an interactive <code>ui://</code> widget in Claude or ChatGPT. Chart types: line, bar, area, pie, scatter, funnel, waterfall, and table. Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>. Bonnard never touches your database.</p>\n<p>Axes, formatting, and gap-fill are determined automatically, so the same typed result produces the same chart every run. The tool returns compact summaries, row caps with completeness flags, and instructive errors with a <code>next_step</code>, so the agent can reason about the result. For the design principles, see <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">how Bonnard builds agent-friendly MCPs</a>.</p>\n<h2 id=\"ai-report-generators-whats-available\">AI report generators: what’s available</h2>\n<p>AI report generators automate the mechanical parts of building reports. Here’s what exists and where each approach fits.</p>\n<p><strong>General-purpose LLMs as report generators.</strong> Upload a CSV to ChatGPT or Claude and ask for an analysis. Good for one-off reports. No governance, no scheduling, no multi-tenancy. The report is as good as the prompt. Ask the same question next month and you might get a different methodology.</p>\n<p><strong>Dedicated AI reporting tools.</strong> Narrative BI, Rath, and similar tools generate automated dashboards and written insights from your data. They connect to your warehouse and produce reports on a schedule. The limitation: they define metrics at report generation time, not in a governed layer. Different reports can calculate the same metric differently.</p>\n<p><strong>BI tools with AI features.</strong> Power BI Copilot, Tableau AI, Domo AI. The AI helps you build reports inside the BI tool. Better than general-purpose LLMs because the data stays in your warehouse. Limited to whatever the BI tool supports.</p>\n<p><strong>Semantic layer + AI.</strong> Define metrics once in a governed layer. Use AI to generate narrative summaries, choose visualizations, and schedule delivery. The AI handles presentation. The semantic layer handles correctness. This is the approach that produces trustworthy automated reports.</p>\n<h2 id=\"ai-reporting-tools-compared\">AI reporting tools compared</h2>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Approach</th>\n<th>Governed metrics</th>\n<th>Multi-tenant reports</th>\n<th>Audit trail</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>ChatGPT / Claude</strong></td>\n<td>Upload data, ask for analysis</td>\n<td>No</td>\n<td>No</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Power BI Copilot</strong></td>\n<td>AI in Power BI</td>\n<td>DAX measures (PBI only)</td>\n<td>Complex</td>\n<td>Within PBI</td>\n</tr>\n<tr>\n<td><strong>Tableau AI</strong></td>\n<td>AI in Tableau</td>\n<td>Tableau model only</td>\n<td>Enterprise</td>\n<td>Within Tableau</td>\n</tr>\n<tr>\n<td><strong>Domo AI</strong></td>\n<td>AI in Domo platform</td>\n<td>Within Domo</td>\n<td>Within Domo</td>\n<td>Within Domo</td>\n</tr>\n<tr>\n<td><strong>Automated BI tools</strong> (Narrative BI, Rath)</td>\n<td>AI-generated dashboards</td>\n<td>No</td>\n<td>No</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Charts in agent (Bonnard)</strong></td>\n<td><code>visualize</code> tool on your MCP server</td>\n<td>N/A (your <code>runSql</code>)</td>\n<td>N/A (your DB)</td>\n<td>N/A</td>\n</tr>\n</tbody>\n</table>\n<h2 id=\"when-to-use-ai-reporting\">When to use AI reporting</h2>\n<p><strong>Use AI reporting with a semantic layer when:</strong></p>\n<ul>\n<li>Reports go to the board, customers, or regulators (trust matters)</li>\n<li>Multiple reports use the same metrics (consistency matters)</li>\n<li>You’re automating B2B customer reports with per-tenant data</li>\n<li>The data team is spending too much time on report generation</li>\n</ul>\n<p><strong>Use raw AI reporting tools when:</strong></p>\n<ul>\n<li>One-off ad-hoc analysis for internal consumption</li>\n<li>Exploring a new dataset where methodology isn’t established</li>\n<li>Speed matters more than consistency</li>\n</ul>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>If you want charts rendered inside an AI agent from your own query results, install the package and register the tool on your MCP server:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p>That registers the <code>visualize</code> tool. Connect your server in Claude or ChatGPT, and the agent can render charts from your query results. Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<p>Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>. Full walkthrough: <a href=\"/blog/mcp-charts\">MCP charts</a>. For background: <a href=\"/blog/what-is-a-semantic-layer\">What Is a Semantic Layer?</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-ai-reporting-1\">What is AI reporting?</h3>\n<p>AI reporting uses large language models to automate report generation: querying data, creating visualizations, writing summaries, and scheduling delivery. The AI handles the mechanical work that analysts currently do manually. The risk: without governed metric definitions, the AI generates different calculations on each run.</p>\n<h3 id=\"can-ai-replace-my-reporting-team\">Can AI replace my reporting team?</h3>\n<p>No. AI can automate the mechanical parts (pulling data, formatting charts, writing summaries). It can’t define what metrics should mean, decide which numbers matter for a specific audience, or judge whether an insight is actionable. The best approach: data teams define governed metrics, AI automates the delivery.</p>\n<h3 id=\"what-is-automated-reporting\">What is automated reporting?</h3>\n<p>Automated reporting is any system that generates reports without manual intervention. This includes scheduled dashboard refreshes, programmatic report generation via API, and AI-generated narrative summaries. The key question isn’t whether to automate, but whether the automated reports use governed metric definitions.</p>\n<h3 id=\"how-do-i-make-ai-reports-trustworthy\">How do I make AI reports trustworthy?</h3>\n<p>Separate metric definition from report generation. Define each metric once in a <a href=\"/glossary/semantic-layer\">semantic layer</a> with a fixed calculation. Let the AI query those definitions instead of generating SQL. Every report references the same versioned definitions. When someone questions a number, you point to a Git commit, not “the AI decided.”</p>\n<h3 id=\"what-is-the-best-approach-to-ai-reporting-for-saas\">What is the best approach to AI reporting for SaaS?</h3>\n<p>Govern the numbers first so methodology stays fixed across runs. Then pick the surface where the report lands. If your customers read reports inside Claude or ChatGPT and want a chart in the conversation, <code>@bonnard/mcp-charts</code> adds a <code>visualize</code> tool to your MCP server that renders interactive charts from your query results. Your <code>runSql</code> runs the query, so Bonnard never touches your database. Install with <code>npm install @bonnard/mcp-charts</code>. Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/analytics-api",
      "url": "https://bonnard.dev/blog/analytics-api",
      "title": "Analytics API: How to Serve Governed Metrics to Any Consumer",
      "summary": "An analytics API exposes your metrics programmatically. How to build one that serves dashboards, AI agents, and integrations from the same definitions.",
      "content_html": "<p>You have metrics. Revenue, active users, churn, usage per customer. They live in your data warehouse. Now you need to serve them to multiple consumers: your product’s frontend, your customers’ integrations, an AI agent, a scheduled report generator.</p>\n<p>The first instinct is custom API endpoints. One endpoint for revenue by region. Another for churn by plan. Another for the customer dashboard. Each endpoint has its own SQL query, its own response format, its own maintenance burden. By the twentieth endpoint, you’re running a bespoke analytics service.</p>\n<p>An analytics API backed by a <a href=\"/glossary/semantic-layer\">semantic layer</a> gives you one query interface that serves every consumer. Define metrics once. Query them from anywhere.</p>\n<h2 id=\"what-is-an-analytics-api\">What is an analytics API?</h2>\n<p>An analytics API is a programmatic interface for querying metrics. Instead of connecting to a database and writing SQL, consumers call an API endpoint with the metrics they want and get structured results back.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">curl</span><span style=\"color:#032F62\"> https://analytics.example.com/v1/query</span><span style=\"color:#005CC5\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">  -H</span><span style=\"color:#032F62\"> \"Authorization: Bearer bon_pk_...\"</span><span style=\"color:#005CC5\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">  -d</span><span style=\"color:#032F62\"> '{</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">    \"measures\": [\"orders.total_revenue\", \"orders.order_count\"],</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">    \"dimensions\": [\"orders.region\"],</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">    \"timeDimensions\": [{</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">      \"dimension\": \"orders.created_at\",</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">      \"granularity\": \"month\",</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">      \"dateRange\": [\"2026-01-01\", \"2026-03-31\"]</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">    }],</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">    \"orderBy\": { \"orders.total_revenue\": \"desc\" }</span></span>\n<span class=\"line\"><span style=\"color:#032F62\">  }'</span></span></code></pre>\n<p>The response is structured JSON:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#24292E\">{</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">  \"data\"</span><span style=\"color:#24292E\">: [</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">    { </span><span style=\"color:#005CC5\">\"orders.region\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"EMEA\"</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">\"orders.total_revenue\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#005CC5\">142000</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">\"orders.order_count\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#005CC5\">340</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">\"orders.created_at\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"2026-01-01\"</span><span style=\"color:#24292E\"> },</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">    { </span><span style=\"color:#005CC5\">\"orders.region\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"APAC\"</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">\"orders.total_revenue\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#005CC5\">98000</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">\"orders.order_count\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#005CC5\">220</span><span style=\"color:#24292E\">, </span><span style=\"color:#005CC5\">\"orders.created_at\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"2026-01-01\"</span><span style=\"color:#24292E\"> }</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  ]</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">}</span></span></code></pre>\n<p>Every consumer uses the same interface. The dashboard frontend, the customer’s API integration, the scheduled report, and the AI agent all query the same endpoint with the same metric definitions. The analytics API handles query generation, execution, caching, and access control.</p>\n<h2 id=\"why-not-just-build-api-endpoints\">Why not just build API endpoints?</h2>\n<p>Custom endpoints work at small scale. They break at medium scale:</p>\n<p><strong>Metric drift.</strong> <code>/api/revenue</code> and <code>/api/dashboard/revenue</code> both return “revenue” but use different SQL queries. One gets updated. The other doesn’t.</p>\n<p><strong>N+1 endpoint problem.</strong> Every new metric or dimension combination is a new endpoint. Revenue by region. Revenue by plan. Revenue by region AND plan. Revenue by region AND plan AND month. The combinatorial explosion is real.</p>\n<p><strong>No caching strategy.</strong> Each endpoint hits the warehouse on every request. Response times grow with data volume. You end up building a caching layer per endpoint.</p>\n<p><strong>No access control.</strong> Each endpoint implements its own auth. Customer A’s key works on all endpoints or none. Per-tenant, per-metric access control is custom code.</p>\n<p>A shared query interface handles all of this. But there’s a gap that shows up once AI agents become consumers: an agent that fetches rows still has to present them. A wall of JSON tells the user nothing. Agents need to chart query results, not just fetch them.</p>\n<h2 id=\"charting-the-results-an-agent-fetches\">Charting the results an agent fetches</h2>\n<p>Fetching rows is half the job. The other half is rendering them so a person can read the answer. That’s where <code>@bonnard/mcp-charts</code> fits: a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results.</p>\n<h3 id=\"add-it-to-your-mcp-server\">Add it to your MCP server</h3>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// runSql runs against your warehouse and returns typed rows</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool on your server. Bonnard never touches your database. Your <code>runSql</code> does, against Postgres, BigQuery, Snowflake, Databricks, DuckDB, or any source you wire up.</p>\n<h3 id=\"how-the-flow-works\">How the flow works</h3>\n<ol>\n<li>The agent calls <code>visualize</code> with a query.</li>\n<li>Your <code>runSql</code> returns rows.</li>\n<li>Bonnard infers the chart from the typed result: line, bar, area, pie, scatter, funnel, waterfall, or table.</li>\n<li>The host renders an interactive <code>ui://</code> widget inside Claude or ChatGPT.</li>\n</ol>\n<p>The agent gets line, bar, and area charts for trends, scatter for distributions, funnel and waterfall for staged data, pie for shares, and a table when a table is the right answer. Axes, formatting, and gap-fill are decided from the typed schema, so the same query produces the same chart every time.</p>\n<p>One query interface for the numbers. One <code>visualize</code> tool for the picture.</p>\n<h2 id=\"analytics-api-vs-alternatives\">Analytics API vs alternatives</h2>\n<table>\n<thead>\n<tr>\n<th>Approach</th>\n<th>Metric governance</th>\n<th>Multi-tenant</th>\n<th>Caching</th>\n<th>Maintenance</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Custom REST endpoints</strong></td>\n<td>None (per-endpoint SQL)</td>\n<td>DIY</td>\n<td>DIY</td>\n<td>High (per endpoint)</td>\n</tr>\n<tr>\n<td><strong>GraphQL API</strong></td>\n<td>Schema-level</td>\n<td>DIY</td>\n<td>Per-resolver</td>\n<td>Medium-high</td>\n</tr>\n<tr>\n<td><strong>Direct warehouse access</strong></td>\n<td>None</td>\n<td>Manual</td>\n<td>None</td>\n<td>Low (but dangerous)</td>\n</tr>\n<tr>\n<td><strong>BI tool API</strong> (Looker, Metabase)</td>\n<td>Tool-specific</td>\n<td>Tool-specific</td>\n<td>Tool-specific</td>\n<td>Medium</td>\n</tr>\n<tr>\n<td><strong>Bonnard</strong></td>\n<td>Inherits your <code>runSql</code> definitions</td>\n<td>Inherits your <code>runSql</code></td>\n<td>Inherits your warehouse</td>\n<td>Low (a few lines on your server). Adds interactive charts via the MCP <code>visualize</code> tool, rendered in Claude/ChatGPT from query results</td>\n</tr>\n</tbody>\n</table>\n<h2 id=\"when-you-need-an-analytics-api\">When you need an analytics API</h2>\n<p><strong>You need one when:</strong></p>\n<ul>\n<li>Multiple consumers query the same metrics (frontend + backend + AI agents)</li>\n<li>Customers integrate with your data via API</li>\n<li>You’re building <a href=\"/glossary/embedded-analytics\">embedded analytics</a> with a frontend SDK</li>\n<li>AI agents need programmatic access to governed data</li>\n<li>Your data team is tired of building custom endpoints</li>\n</ul>\n<p><strong>You don’t need one when:</strong></p>\n<ul>\n<li>A single dashboard covers all use cases</li>\n<li>Nobody queries your metrics programmatically</li>\n<li>The data model is so simple that direct SQL is fine</li>\n</ul>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>Once your analytics API can fetch rows, add a <code>visualize</code> tool so agents can chart them:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>That registers the <code>visualize</code> tool on your MCP server. Agents call it with a query, your <code>runSql</code> returns rows, and Bonnard renders an interactive chart inside Claude or ChatGPT. Adapters ship for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<p>For the full walkthrough: <a href=\"/blog/mcp-charts\">Add Interactive Charts to Your MCP Server</a>. For the design choices behind the tool: <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">How Bonnard Builds Agent-Friendly MCPs</a>.</p>\n<p>Source is on <a href=\"https://github.com/bonnard-data/mcp-charts\">GitHub</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-an-analytics-api-1\">What is an analytics API?</h3>\n<p>An analytics API is a programmatic interface for querying business metrics. Instead of writing SQL against a database, consumers call an API with the measures, dimensions, and filters they want. The API handles query generation, caching, and access control.</p>\n<h3 id=\"how-is-an-analytics-api-different-from-a-rest-api\">How is an analytics API different from a REST API?</h3>\n<p>A generic REST API exposes resources (users, orders, invoices). An analytics API exposes metrics (revenue, churn rate, active users) with aggregation, filtering, and time dimensions built in. The query interface is designed for analytical questions, not CRUD operations.</p>\n<h3 id=\"do-i-need-an-analytics-api-for-ai-agents\">Do I need an analytics API for AI agents?</h3>\n<p>If AI agents query your data, an analytics API is the governed path. The alternative is giving agents direct database access (dangerous) or text-to-SQL (inconsistent). An analytics API backed by a semantic layer gives agents governed access to metric definitions. See <a href=\"/blog/what-is-agentic-semantic-layer\">What Is an Agentic Semantic Layer?</a>.</p>\n<h3 id=\"what-about-graphql-for-analytics\">What about GraphQL for analytics?</h3>\n<p>GraphQL works for analytics APIs but you end up building the aggregation, caching, and access control layer yourself. A semantic layer provides these out of the box with a purpose-built query interface for analytical workloads.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/best-embedded-analytics-tools",
      "url": "https://bonnard.dev/blog/best-embedded-analytics-tools",
      "title": "Best Embedded Analytics Tools for SaaS in 2026",
      "summary": "Comparing the best embedded analytics tools for B2B SaaS in 2026: Metabase, Explo, Luzmo, GoodData, Looker, and the MCP-native option for AI agents.",
      "content_html": "<p>If you’re building a B2B SaaS product and need to ship analytics to your customers, you’re evaluating embedded analytics tools. The market has more options than it needs. This guide cuts through the noise.</p>\n<p>We compare 10 tools across the dimensions that matter for B2B: multi-tenancy, customization, AI agent support, pricing model, and how many surfaces each tool serves beyond dashboards.</p>\n<h2 id=\"what-to-look-for-in-embedded-analytics\">What to look for in embedded analytics</h2>\n<p>Before comparing tools, know what matters for your use case:</p>\n<p><strong>Multi-tenancy.</strong> Every customer sees only their data. This needs to be structural (enforced at the query layer), not bolted on (middleware filters that might miss an edge case). One data leak and your customers’ trust is gone.</p>\n<p><strong>Customization.</strong> The analytics should look like part of your product. Iframe embedding limits this. Native SDK components give you full control over styling.</p>\n<p><strong>Metric governance.</strong> Every chart should show the same number as your API. If the dashboard says $45K and the API returns $43K, your customer notices.</p>\n<p><strong>Performance.</strong> Dashboards that take 8 seconds to load don’t get used. Caching matters.</p>\n<p><strong>Pricing model.</strong> Per-user pricing kills B2B use cases. If you have 1,000 customers each with 10 users, per-seat licensing makes embedded analytics unaffordable.</p>\n<p><strong>AI agent support.</strong> Increasingly, customers want to connect AI tools to their data. None of the traditional embedded analytics tools support this. It’s a differentiator.</p>\n<h2 id=\"the-10-tools-compared\">The 10 tools compared</h2>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Type</th>\n<th>Multi-tenancy</th>\n<th>AI/Agent</th>\n<th>Pricing</th>\n<th>Open source</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong><a href=\"/vs-metabase\">Metabase</a></strong></td>\n<td>BI tool with embedding</td>\n<td>Enterprise only ($500+/mo)</td>\n<td>No</td>\n<td>Free OSS / $500+ Pro</td>\n<td>Yes (AGPL)</td>\n</tr>\n<tr>\n<td><strong>Holistics</strong></td>\n<td>Embedded BI platform</td>\n<td>Built-in</td>\n<td>No</td>\n<td>Per-user</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Explo</strong></td>\n<td>Embedded analytics SDK</td>\n<td>Built-in</td>\n<td>No</td>\n<td>Usage-based</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Luzmo</strong></td>\n<td>Embedded analytics SDK</td>\n<td>Built-in</td>\n<td>Limited</td>\n<td>Usage-based</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Reveal</strong></td>\n<td>Embedded analytics SDK</td>\n<td>Built-in</td>\n<td>No</td>\n<td>Per-app</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>GoodData</strong></td>\n<td>Enterprise embedded BI</td>\n<td>Built-in</td>\n<td>Limited</td>\n<td>Usage-based</td>\n<td>Partial</td>\n</tr>\n<tr>\n<td><strong><a href=\"/vs-looker\">Looker</a></strong></td>\n<td>Enterprise BI with embedding</td>\n<td>Enterprise</td>\n<td>No</td>\n<td>Enterprise pricing</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong><a href=\"/vs-power-bi\">Power BI Embedded</a></strong></td>\n<td>Enterprise BI with embedding</td>\n<td>Complex setup</td>\n<td>Copilot (internal)</td>\n<td>Capacity-based</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong><a href=\"/vs-tableau\">Tableau Embedded</a></strong></td>\n<td>Enterprise BI with embedding</td>\n<td>Enterprise</td>\n<td>Limited</td>\n<td>Per-user + Server</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong><a href=\"https://bonnard.dev\">Bonnard</a></strong></td>\n<td>Charts for AI agents via MCP (not embedded BI)</td>\n<td>N/A (your <code>runSql</code>, your DB)</td>\n<td>Native (<code>visualize</code> tool)</td>\n<td>Open source (npm)</td>\n<td>Yes</td>\n</tr>\n</tbody>\n</table>\n<h2 id=\"tool-by-tool-breakdown\">Tool-by-tool breakdown</h2>\n<h3 id=\"metabase\">Metabase</h3>\n<p>The most popular open-source BI tool. Great for internal dashboards. Embedding requires Pro ($500+/mo) for static embeds or Enterprise for interactive. Multi-tenancy (sandboxing) is Enterprise only. No semantic layer, no AI agent support.</p>\n<p><strong>Best for:</strong> Internal dashboards, MVPs, teams that want free self-hosted BI.\n<strong>Not great for:</strong> Customer-facing B2B analytics at scale. Multi-tenancy and embedding costs add up.</p>\n<h3 id=\"holistics\">Holistics</h3>\n<p>Self-service embedded BI aimed at product teams. Built-in multi-tenancy with row-level security. Strong data modeling layer. Drag-and-drop dashboard builder with embedding.</p>\n<p><strong>Best for:</strong> Teams that want a managed embedded BI platform with a modeling layer.\n<strong>Not great for:</strong> AI agent use cases, custom React components, or teams that want open-source infrastructure.</p>\n<h3 id=\"explo\">Explo</h3>\n<p>SDK-first embedded analytics. React and JavaScript components for building dashboards in your product. Built-in multi-tenancy. Good API for programmatic control.</p>\n<p><strong>Best for:</strong> Product teams that want prebuilt dashboard components with customization.\n<strong>Not great for:</strong> Teams that need governed metrics across multiple surfaces beyond dashboards. No semantic layer, no AI agent support.</p>\n<h3 id=\"luzmo\">Luzmo</h3>\n<p>Embedded analytics with a focus on developer experience. SDK components, API access, and a dashboard builder. Some AI capabilities for insight generation.</p>\n<p><strong>Best for:</strong> Small to mid-size SaaS products that want quick embedded dashboards.\n<strong>Not great for:</strong> Enterprise multi-tenancy, AI agent integration, or teams that need metrics governance.</p>\n<h3 id=\"reveal\">Reveal</h3>\n<p>Embedded analytics SDK targeting .NET and Java applications. Rich visualization library. Built-in multi-tenancy.</p>\n<p><strong>Best for:</strong> .NET and Java shops building enterprise applications.\n<strong>Not great for:</strong> React/TypeScript teams, AI agent use cases, or modern data stack.</p>\n<h3 id=\"gooddata\">GoodData</h3>\n<p>Enterprise embedded analytics platform. Strong multi-tenancy, API access, and metric definitions (“metrics as code” via MAQL). Partial open-source.</p>\n<p><strong>Best for:</strong> Large SaaS companies with enterprise analytics requirements.\n<strong>Not great for:</strong> Smaller teams, open-source-first teams, or AI agent integration.</p>\n<h3 id=\"looker\">Looker</h3>\n<p>Google Cloud’s BI platform. LookML provides a strong semantic layer, but it’s locked to Looker’s ecosystem. Enterprise pricing. Embedding requires Looker Embed API.</p>\n<p><strong>Best for:</strong> Google Cloud teams with enterprise budgets that need a mature semantic layer.\n<strong>Not great for:</strong> Teams outside the Google ecosystem, budget-conscious products, or AI agent use cases.</p>\n<h3 id=\"power-bi-embedded\">Power BI Embedded</h3>\n<p>Microsoft’s embedded BI. Capacity-based pricing (SKUs). Deep Azure integration. Complex multi-tenancy setup requiring Azure AD.</p>\n<p><strong>Best for:</strong> Microsoft shops with Azure infrastructure.\n<strong>Not great for:</strong> Non-Microsoft teams, simple multi-tenancy, or AI agent access.</p>\n<h3 id=\"tableau-embedded\">Tableau Embedded</h3>\n<p>Salesforce’s visualization platform. Rich charting. Embedding requires Tableau Server/Cloud plus custom auth. Per-seat pricing.</p>\n<p><strong>Best for:</strong> Organizations deeply invested in Tableau with budget for per-seat licensing.\n<strong>Not great for:</strong> B2B products with many end users (per-seat cost is prohibitive). No AI agent support.</p>\n<h3 id=\"bonnard\">Bonnard</h3>\n<p>Not an embedded BI tool. <code>@bonnard/mcp-charts</code> is a <code>visualize</code> tool you add to your MCP server. You call <code>addCharts(server, { runSql })</code>, and it registers a <code>visualize</code> tool that renders interactive charts from your query results, inside the agent. When the agent asks for a chart, your <code>runSql</code> returns rows, Bonnard infers the chart from the typed result, and renders an interactive <code>ui://</code> widget in Claude or ChatGPT. Chart types: line, bar, area, pie, scatter, funnel, waterfall, and table (with stacked, grouped, horizontal, and 100% bar variants). Native adapters for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or your own <code>runSql</code>. Bonnard never touches your database.</p>\n<p><strong>Best for:</strong> Teams already building an MCP server who want interactive charts inside Claude or ChatGPT, rendered from their own query results.\n<strong>Not great for:</strong> Teams that want in-product dashboards for non-technical users. This is charts for AI agents, not an embedded dashboard tool.</p>\n<h2 id=\"decision-framework\">Decision framework</h2>\n<p><strong>Choose dashboard embedding (Metabase, Holistics, Explo, Luzmo, Reveal)</strong> if:</p>\n<ul>\n<li>Dashboards are the only analytics surface you need</li>\n<li>You want a prebuilt UI your customers interact with</li>\n<li>AI agent access is not a requirement</li>\n<li>You don’t need the same metrics in APIs and SDKs</li>\n</ul>\n<p><strong>Choose enterprise BI embedding (Looker, Power BI, Tableau)</strong> if:</p>\n<ul>\n<li>You’re already in that vendor’s ecosystem</li>\n<li>Enterprise pricing fits your model</li>\n<li>Internal + embedded analytics in one tool matters</li>\n<li>You have dedicated BI administrators</li>\n</ul>\n<p><strong>Choose charts for AI agents via MCP (Bonnard)</strong> if:</p>\n<ul>\n<li>You’re building an MCP server and want interactive charts inside Claude or ChatGPT</li>\n<li>You want charts rendered from your own query results, not a separate dashboard product</li>\n<li>Your <code>runSql</code> returns rows and you want Bonnard to infer the chart from the typed result</li>\n<li>You want open source with a one-line install, not a dashboard license</li>\n<li>This is adjacent to embedded BI, not a replacement for it</li>\n</ul>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-the-best-free-embedded-analytics-tool\">What is the best free embedded analytics tool?</h3>\n<p>Metabase (AGPL) is the most popular free option for dashboards. Superset (Apache 2.0) is free for SQL-native dashboards. If you want charts inside an AI agent rather than an embedded dashboard, <code>@bonnard/mcp-charts</code> is a free, open-source <code>visualize</code> tool for your MCP server. “Free” depends on what you need: basic charting, full embedded BI, or interactive charts inside Claude or ChatGPT.</p>\n<h3 id=\"what-is-embedded-analytics\">What is embedded analytics?</h3>\n<p><a href=\"/glossary/embedded-analytics\">Embedded analytics</a> is analytics integrated directly into your product for end users. Instead of linking customers to a separate BI tool, charts and metrics appear inside your application. Ranges from basic iframe embedding to native SDK components with governed metrics.</p>\n<h3 id=\"how-much-does-embedded-analytics-cost-for-saas\">How much does embedded analytics cost for SaaS?</h3>\n<p>Free (Metabase OSS) to enterprise pricing (Looker, Tableau). Mid-range tools (Explo, Holistics, Luzmo) charge per-user or usage-based, typically $500-5,000/mo. If your need is charts inside an AI agent rather than an embedded dashboard, <code>@bonnard/mcp-charts</code> is open source and installs via npm.</p>\n<h3 id=\"which-embedded-analytics-tool-has-the-best-multi-tenancy\">Which embedded analytics tool has the best multi-tenancy?</h3>\n<p>Tools with structural multi-tenancy (enforced at the query layer): Explo, Holistics. Tools with configuration-based multi-tenancy: Metabase Enterprise (sandboxing), Power BI (Azure AD + RLS). Tools with no built-in multi-tenancy: Metabase OSS, Superset, Redash.</p>\n<h3 id=\"do-any-embedded-analytics-tools-support-ai-agents\">Do any embedded analytics tools support AI agents?</h3>\n<p>The traditional embedded analytics tools render charts in your product, not inside an AI agent. Some enterprise tools (Power BI Copilot, Luzmo, GoodData) have limited AI features but none render interactive charts inside an MCP host. If you want charts inside the agent, that’s a different category: <code>@bonnard/mcp-charts</code> adds a <code>visualize</code> tool to your MCP server that renders interactive charts from your query results in Claude or ChatGPT. Install it with <code>npm install @bonnard/mcp-charts</code>. Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>. More in the <a href=\"/blog/mcp-charts\">MCP charts guide</a>.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/customer-facing-analytics-for-saas",
      "url": "https://bonnard.dev/blog/customer-facing-analytics-for-saas",
      "title": "How to Build Customer-Facing Analytics for B2B SaaS",
      "summary": "A guide to customer-facing analytics 2026 for B2B SaaS: the options, the tradeoffs, and the MCP-native way to put interactive charts inside an AI agent.",
      "content_html": "<p>Your B2B customers want to see their data. Usage metrics, billing summaries, conversion funnels, performance dashboards. Every customer expects analytics inside your product. They shouldn’t have to ask your support team for a CSV export.</p>\n<p>The question isn’t whether to ship customer-facing analytics. It’s how.</p>\n<p>Most teams start with one of two approaches. They embed a BI tool (Metabase, Looker, Power BI) and fight with multi-tenancy, iframe styling, and paid embedding licenses. Or they build custom charts from scratch and spend months maintaining SQL queries, API endpoints, and frontend components that nobody asked for.</p>\n<p>Both approaches burn engineering time on the wrong problem. You end up building analytics infrastructure instead of your product.</p>\n<p>And there’s a surface most of these tools miss entirely: the AI agent. Customers increasingly want to ask questions about their data inside Claude or ChatGPT and get a chart back. That’s a different problem from embedding a dashboard, and it’s where <code>@bonnard/mcp-charts</code> fits, covered later in this post.</p>\n<h2 id=\"why-embedded-bi-tools-fall-short\">Why embedded BI tools fall short</h2>\n<p>Embedding a BI tool sounds fast. Drop in an iframe, connect to your database, ship it. In practice, the friction shows up quickly.</p>\n<h3 id=\"multi-tenancy-is-an-afterthought\">Multi-tenancy is an afterthought</h3>\n<p>Most BI tools were built for internal teams, not B2B products serving hundreds of tenants. Multi-tenancy is either missing, manual, or gated behind an enterprise license.</p>\n<p><a href=\"/vs-metabase\">Metabase</a> requires the Enterprise license ($500+/month) for row-level permissions and sandboxed embedding. The open-source version has basic embedding but no tenant isolation. You end up writing middleware to filter queries by tenant, which is exactly the custom infrastructure you were trying to avoid.</p>\n<p>Looker’s embedded analytics requires an enterprise contract. Power BI Embedded uses capacity-based pricing that gets expensive at scale. Tableau’s embedding story is Salesforce-priced.</p>\n<p>Even when multi-tenancy is available, it’s usually dashboard-level or role-based, not per-query structural enforcement. You’re trusting the BI tool to filter correctly on every query. One misconfiguration and Customer A sees Customer B’s data.</p>\n<h3 id=\"styling-and-ux-limitations\">Styling and UX limitations</h3>\n<p>An iframe is a foreign element in your product. It looks like a foreign element. Matching your brand’s fonts, colors, spacing, and interaction patterns inside an embedded BI tool ranges from difficult to impossible. Your customers notice.</p>\n<p>White-label analytics means your customers shouldn’t know they’re using a third-party tool. Most embedded BI solutions make this hard. The ones that make it easy charge for it.</p>\n<h3 id=\"youre-locked-to-dashboards\">You’re locked to dashboards</h3>\n<p>Embedded BI gives you dashboards. That’s one surface. But your customers might also want:</p>\n<ul>\n<li>An API to pull metrics into their own tools</li>\n<li>AI agents that answer questions about their data</li>\n<li>Scheduled email reports</li>\n<li>Webhook alerts when metrics cross thresholds</li>\n<li>CSV/Excel exports for their finance team</li>\n</ul>\n<p>Each of these requires a different integration, often with different tools and different metric definitions. The dashboard shows one number. The API returns a different one. The export uses yet another query. The metrics drift.</p>\n<h2 id=\"why-building-from-scratch-is-worse\">Why building from scratch is worse</h2>\n<p>The alternative: skip the BI tool and build it yourself. Custom SQL queries, custom API endpoints, custom React charts.</p>\n<p>This works for the first dashboard. Then the second. By the tenth, you’re maintaining a bespoke analytics platform. Every new metric means a new SQL query, a new API endpoint, a new frontend component, and a new set of tests. Your data engineers are writing API handlers instead of defining metrics. Your frontend engineers are debugging chart edge cases instead of building product features.</p>\n<p>The real cost isn’t the initial build. It’s the ongoing maintenance:</p>\n<ul>\n<li><strong>Metric drift.</strong> Revenue is calculated differently in the dashboard, the API, and the export. Nobody notices until a customer complains.</li>\n<li><strong>No caching layer.</strong> Every API call hits the warehouse. Response times grow with data volume. Your customers experience slow dashboards at month-end when they need them most.</li>\n<li><strong>Security is DIY.</strong> You build tenant filtering yourself. You test it yourself. You hope you didn’t miss an edge case.</li>\n<li><strong>No schema evolution.</strong> Adding a new dimension means updating every query, endpoint, and component that touches the affected metric. A one-line change in business logic cascades into a multi-day project.</li>\n</ul>\n<p>The teams on Reddit asking <a href=\"https://www.reddit.com/r/analytics/comments/1pxjm1z/\">“Is embedded analytics for SaaS actually worth it vs building your own charts?”</a> are wrestling with exactly this tradeoff. There’s no single right answer. The decision depends on how many surfaces you serve and whether your customers want charts in a dashboard, an API, or an AI agent.</p>\n<h2 id=\"the-ai-agent-surface-charts-inside-any-mcp-apps-client\">The AI agent surface: charts inside any MCP Apps client</h2>\n<p>The sections above cover the two classic customer-facing analytics options: embed a BI tool or build dashboards from scratch. There’s a third surface that neither covers well: the AI agent. When your customer asks a question in Claude or ChatGPT and wants a chart back, an embedded dashboard doesn’t help.</p>\n<p><code>@bonnard/mcp-charts</code> is the MCP-native option for that surface. It is not an embedded BI tool, a dashboard product, or a multi-tenant analytics platform. It is a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results, inside the agent.</p>\n<h3 id=\"how-it-works\">How it works</h3>\n<p>Install the package and call <code>addCharts</code> with your existing MCP server and a <code>runSql</code> function:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Run the query against your database and return typed rows.</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    return</span><span style=\"color:#D73A49\"> await</span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql);</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  },</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool on your server. The flow:</p>\n<ol>\n<li>The agent calls <code>visualize</code> with a query.</li>\n<li>Your <code>runSql</code> returns rows.</li>\n<li>Bonnard infers the chart from the typed result.</li>\n<li>It renders an interactive <code>ui://</code> widget in Claude or ChatGPT.</li>\n</ol>\n<p>Chart types: line, bar, area, pie, scatter, funnel, waterfall, and table. Bar charts have stacked, grouped, horizontal, and 100% variants, and a donut is a pie variant. Axes, formatting, and gap-fill are determined automatically, so the same query produces the same chart.</p>\n<p>Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB. You can also pass your own <code>runSql</code>. Bonnard never touches your database. Your code runs the query and returns the rows.</p>\n<h3 id=\"built-for-the-agent-not-only-the-human\">Built for the agent, not only the human</h3>\n<p>The <code>visualize</code> tool returns compact summaries, row caps with completeness flags, and a typed schema so the agent can reason about the result. Errors are instructive and include a <code>next_step</code>, so when a query fails the agent knows what to try next. For how this is designed, see <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">how Bonnard builds agent-friendly MCPs</a>.</p>\n<h2 id=\"build-vs-embed-vs-charts-in-the-agent\">Build vs. embed vs. charts in the agent</h2>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>Build from scratch</th>\n<th>Embed BI tool</th>\n<th>Charts in AI agent (MCP)</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Surface</strong></td>\n<td>In your product UI</td>\n<td>In your product UI</td>\n<td>Inside Claude or ChatGPT</td>\n</tr>\n<tr>\n<td><strong>Time to first chart</strong></td>\n<td>2-4 months</td>\n<td>1-2 weeks</td>\n<td>A few lines (<code>addCharts</code>)</td>\n</tr>\n<tr>\n<td><strong>What renders the chart</strong></td>\n<td>Your frontend code</td>\n<td>The BI tool</td>\n<td>The agent host (<code>ui://</code> widget)</td>\n</tr>\n<tr>\n<td><strong>Where the query runs</strong></td>\n<td>Your code</td>\n<td>The BI tool</td>\n<td>Your <code>runSql</code>, your database</td>\n</tr>\n<tr>\n<td><strong>Chart types</strong></td>\n<td>Whatever you build</td>\n<td>Tool’s library</td>\n<td>line, bar, area, pie, scatter, funnel, waterfall, table</td>\n</tr>\n<tr>\n<td><strong>Maintenance burden</strong></td>\n<td>High (you own everything)</td>\n<td>Medium (tool updates, iframes)</td>\n<td>Low (one tool registration)</td>\n</tr>\n<tr>\n<td><strong>AI agent support</strong></td>\n<td>Build from scratch</td>\n<td>None</td>\n<td>Native (<code>visualize</code> tool)</td>\n</tr>\n</tbody>\n</table>\n<p>These are not competing for the same slot. Embedded BI and a from-scratch build put charts in your product UI. Charts in the agent put them inside Claude or ChatGPT. Many teams will do both.</p>\n<h2 id=\"what-makes-the-ai-agent-surface-different\">What makes the AI agent surface different</h2>\n<p>Embedded analytics tools (Metabase Embedded, Holistics, Explo, Luzmo, Reveal) give you dashboards inside your product. That’s their scope. They’re good at it. None of them render a chart inside an AI agent.</p>\n<p><code>@bonnard/mcp-charts</code> is the adjacent option, not a member of the embedded BI category:</p>\n<p><strong>1. The chart lives in the agent.</strong> When a customer asks a question in Claude or ChatGPT, the <code>visualize</code> tool renders an interactive <code>ui://</code> widget right in the conversation. An embedded dashboard can’t do that.</p>\n<p><strong>2. Your database stays yours.</strong> Bonnard never touches your database. Your <code>runSql</code> runs the query and returns the rows. Bonnard infers the chart from the typed result.</p>\n<p><strong>3. Designed for the agent.</strong> Compact summaries, row caps with completeness flags, typed schema, and instructive errors with a <code>next_step</code>. The tool is built for an agent to call, not only for a human to read.</p>\n<p>The tradeoff: embedded BI tools render charts in your product UI, which is where many customers expect them. <code>@bonnard/mcp-charts</code> only covers the agent surface. If your customers want both a dashboard and an in-agent chart, use an embedded BI tool for the dashboard and <code>@bonnard/mcp-charts</code> for the agent.</p>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>Install the package and register the tool on your MCP server:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p>That registers the <code>visualize</code> tool. Connect your server in Claude or ChatGPT, and the agent can render charts from your query results. Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<p>Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>. For the full walkthrough: <a href=\"/blog/mcp-charts\">MCP charts</a>. For the design principles: <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">how Bonnard builds agent-friendly MCPs</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-customer-facing-analytics\">What is customer-facing analytics?</h3>\n<p>Customer-facing analytics is analytics embedded in your product for your customers to use. Instead of internal dashboards for your team, the analytics are exposed to end users: your B2B customers, their teams, and their tools. The key challenges are multi-tenancy (each customer sees only their data), performance (customers expect fast load times), and consistency (the numbers should match across every surface).</p>\n<h3 id=\"what-is-embedded-analytics\">What is embedded analytics?</h3>\n<p><a href=\"/glossary/embedded-analytics\">Embedded analytics</a> means integrating analytics capabilities directly into another application. This can be as simple as an iframe embedding a dashboard or as sophisticated as native React components querying a governed API. The term covers a range of approaches from basic chart embedding to full white-label analytics platforms.</p>\n<h3 id=\"how-is-this-different-from-metabase-embedding\">How is this different from Metabase embedding?</h3>\n<p><a href=\"/vs-metabase\">Metabase</a> offers embedded dashboards via iframe or full-app embedding. The open-source version has basic embedding but no tenant isolation. The Enterprise version ($500+/month) adds row-level permissions and sandboxed embedding. Metabase renders charts in your product UI. <code>@bonnard/mcp-charts</code> is a different surface entirely: it renders interactive charts inside an AI agent (Claude or ChatGPT) via a <code>visualize</code> tool on your MCP server. They are not substitutes. Use Metabase for an in-product dashboard, and <code>@bonnard/mcp-charts</code> for charts in the agent.</p>\n<h3 id=\"what-is-white-label-analytics\">What is white-label analytics?</h3>\n<p>White-label analytics means your customers see your brand, not a third-party tool’s brand. No “Powered by Metabase” footer. No foreign-looking iframe. The analytics feel native to your product. Most embedded BI tools charge for white-labeling, and the harder it is to match your design, the more obvious the third-party tool becomes. This is a property of in-product embedding, not of charts rendered inside an AI agent.</p>\n<h3 id=\"what-is-customer-facing-analytics-for-ai-agents\">What is customer-facing analytics for AI agents?</h3>\n<p>It is letting your customers ask questions about their data inside an AI agent (Claude or ChatGPT) and get a chart back. <code>@bonnard/mcp-charts</code> covers this with a <code>visualize</code> tool you add to your MCP server. The agent calls <code>visualize</code>, your <code>runSql</code> returns rows, and Bonnard renders an interactive chart in the conversation. This is the 2026 surface that embedded BI tools do not address.</p>\n<h3 id=\"can-my-customers-get-charts-inside-an-ai-agent\">Can my customers get charts inside an AI agent?</h3>\n<p>Yes, if you expose an MCP server. Add <code>@bonnard/mcp-charts</code> with <code>addCharts(server, { runSql })</code>, and the agent can call <code>visualize</code> to render interactive charts from your query results in Claude or ChatGPT. Your <code>runSql</code> runs the query against your database, so Bonnard never touches your data. See <a href=\"/glossary/mcp\">MCP</a> for how the protocol works.</p>\n<h3 id=\"what-databases-does-bonnardmcp-charts-work-with\">What databases does @bonnard/mcp-charts work with?</h3>\n<p>Native adapters cover <a href=\"/integrations/postgres\">Postgres</a>, <a href=\"/integrations/bigquery\">BigQuery</a>, <a href=\"/integrations/snowflake\">Snowflake</a>, <a href=\"/integrations/databricks\">Databricks</a>, and <a href=\"/integrations/duckdb\">DuckDB</a>. You can also pass your own <code>runSql</code> function to query anything that returns rows. Bonnard never connects to your database directly.</p>\n<h3 id=\"how-much-does-bonnardmcp-charts-cost\">How much does @bonnard/mcp-charts cost?</h3>\n<p>It is open source. Install it with <code>npm install @bonnard/mcp-charts</code>. Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/kpi-dashboard",
      "url": "https://bonnard.dev/blog/kpi-dashboard",
      "title": "KPI Dashboards Are Broken. Here's What Replaces Them.",
      "summary": "KPI dashboards show stale numbers nobody trusts. Governed metrics through a semantic layer give every consumer the same live data, dashboards to AI agents.",
      "content_html": "<p>Your company has a KPI dashboard. It was built six months ago by someone who has since moved teams. It shows revenue, churn, and a few product metrics. It loads slowly. The numbers don’t match what finance reports. Nobody trusts it, but everyone screenshots it for the Monday standup.</p>\n<p>This is the state of KPI dashboards at most companies. Not because the tools are bad, but because the approach is wrong. A dashboard is a static view of a dynamic system. The moment someone builds it, it starts drifting from reality.</p>\n<h2 id=\"what-is-a-kpi-dashboard\">What is a KPI dashboard?</h2>\n<p>A KPI (Key Performance Indicator) dashboard is a visual display of an organization’s most important metrics. Revenue, customer count, churn rate, conversion rate, average order value, NPS. The metrics that tell you whether the business is healthy.</p>\n<p>Traditional KPI dashboards live in a BI tool: Power BI, Tableau, Looker, Metabase, Grafana. An analyst builds the dashboard, connects it to a data source, and shares a link. People visit the dashboard (or receive a scheduled screenshot) to check the numbers.</p>\n<p>The concept is sound. The execution breaks for predictable reasons.</p>\n<h2 id=\"kpi-dashboard-examples\">KPI dashboard examples</h2>\n<p>Before diving into what’s broken, here’s what teams typically build:</p>\n<p><strong>SaaS executive dashboard.</strong> MRR, ARR, net revenue retention, churn rate, new customers this month, average contract value. Updated daily. Viewed by the CEO and board. The numbers must match the finance team’s report exactly.</p>\n<p><strong>Product analytics dashboard.</strong> Daily active users, feature adoption rates, conversion funnel stages, time to value. Updated hourly. Viewed by product managers. Often the first dashboard built and the first to drift from reality.</p>\n<p><strong>Customer health dashboard (B2B).</strong> Per-customer usage metrics, support ticket volume, NPS, renewal risk score. Updated daily. Viewed by customer success. In embedded analytics use cases, the customer sees this too.</p>\n<p><strong>Engineering operations dashboard.</strong> API error rates, p95 latency, deployment frequency, uptime. Updated real-time. Viewed by engineering leads. Often built in Grafana rather than a BI tool.</p>\n<p><strong>Sales pipeline dashboard.</strong> Open deals by stage, weighted pipeline value, win rate, average deal cycle. Updated daily. Viewed by sales leadership. Usually lives in Salesforce or a connected BI tool.</p>\n<p>Each of these follows the same pattern: connect to a data source, write queries, build charts, share a link. The problems start when multiple dashboards need the same metric (revenue, active users, churn) and define it differently.</p>\n<h2 id=\"why-kpi-dashboards-fail\">Why KPI dashboards fail</h2>\n<h3 id=\"the-metrics-drift\">The metrics drift</h3>\n<p>Revenue is defined in the dashboard as <code>SUM(amount) WHERE status = 'completed'</code>. Finance defines it as <code>SUM(amount) WHERE status != 'refunded' AND type != 'trial'</code>. The CFO looks at the dashboard, compares it to the finance report, and stops trusting the dashboard. This happens every quarter at companies without a shared metric layer.</p>\n<p>The root cause: business logic is defined in the dashboard, not in a governed layer. Every dashboard that shows revenue re-implements the calculation. Each implementation drifts independently.</p>\n<h3 id=\"nobody-opens-them\">Nobody opens them</h3>\n<p>Dashboards that load in 8 seconds don’t get used. Dashboards behind a BI tool login don’t get used. Dashboards that require navigating to a separate tool don’t get used. The data team builds 50 dashboards. Three get regular traffic. The rest are abandoned.</p>\n<p>The data team keeps building new ones because stakeholders keep asking. Each request means a new dashboard, a new query, a new maintenance burden. The team becomes a dashboard factory instead of building the metrics layer that would make dashboards unnecessary.</p>\n<h3 id=\"they-only-serve-one-surface\">They only serve one surface</h3>\n<p>A KPI dashboard in Looker serves people who open Looker. What about:</p>\n<ul>\n<li>The product team that wants metrics in their React app?</li>\n<li>The customer success team that wants account health in Slack?</li>\n<li>The AI agent that needs to answer “how’s revenue trending?”</li>\n<li>The B2B customer that wants their own usage dashboard inside your product?</li>\n<li>The executive who wants a weekly email summary?</li>\n</ul>\n<p>Each of these requires a different integration. The dashboard metric definitions don’t transfer. You end up rebuilding the same KPIs in every tool, with each copy drifting independently.</p>\n<h3 id=\"stale-data-stale-insight\">Stale data, stale insight</h3>\n<p>Most KPI dashboards refresh on a schedule: hourly, daily, sometimes manually. Between refreshes, the numbers are stale. For operational KPIs (active users right now, orders this hour, API error rate), stale data is useless data.</p>\n<p>Even with real-time refresh, the dashboard is a passive display. It shows numbers. It doesn’t answer questions. “Revenue is down 12% this week” is visible on the dashboard. “Why?” requires a human to open the BI tool, write a query, drill into dimensions, and figure it out. An AI agent could answer that question in seconds, but the dashboard can’t feed an agent.</p>\n<h2 id=\"the-alternative-a-kpi-chart-the-agent-renders-on-demand\">The alternative: a KPI chart the agent renders on demand</h2>\n<p>The static dashboard is a snapshot someone built once. The agent path is different: instead of opening a tool and reading a chart someone else laid out, you ask “how’s revenue trending?” and the agent runs the query and draws the chart in the conversation.</p>\n<p>That last step (drawing the chart) is what <code>@bonnard/mcp-charts</code> adds: a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results. The agent already has access to your data. The <code>visualize</code> tool turns the rows it gets back into a chart the person can actually read.</p>\n<h3 id=\"add-the-visualize-tool\">Add the visualize tool</h3>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// runSql runs against your warehouse and returns typed rows</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool on your MCP server. Bonnard never touches your database. Your <code>runSql</code> does, against Postgres, BigQuery, Snowflake, Databricks, DuckDB, or any source you wire up.</p>\n<h3 id=\"how-a-kpi-request-flows\">How a KPI request flows</h3>\n<ol>\n<li>Someone asks the agent “how’s MRR trending this quarter?”</li>\n<li>The agent calls <code>visualize</code> with a query, and your <code>runSql</code> returns the rows.</li>\n<li>Bonnard infers the chart from the typed result: a line chart for MRR over time, a bar chart for revenue by plan, a funnel for a conversion sequence.</li>\n<li>Claude or ChatGPT renders an interactive <code>ui://</code> widget right in the conversation.</li>\n</ol>\n<p>The chart types cover the KPI cases you’d reach for on a dashboard: line, bar, and area for trends, bar variants (stacked, grouped, horizontal, 100%) for breakdowns, pie for shares, scatter for distributions, funnel and waterfall for staged metrics, and a table when the answer is a table. Axes, number formatting, and gap-fill come from the typed schema, so the same KPI query renders the same chart every time.</p>\n<p>No dashboard to patch. The KPI gets drawn the moment someone asks, from whatever query answers the question.</p>\n<h2 id=\"kpi-dashboard-tools-compared\">KPI dashboard tools compared</h2>\n<table>\n<thead>\n<tr>\n<th>Approach</th>\n<th>Metric governance</th>\n<th>Surfaces</th>\n<th>AI agent support</th>\n<th>Multi-tenant</th>\n<th>Performance</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Power BI dashboard</strong></td>\n<td>DAX (locked to PBI)</td>\n<td>Power BI only</td>\n<td>Copilot (internal)</td>\n<td>Complex</td>\n<td>Import mode or DirectQuery</td>\n</tr>\n<tr>\n<td><strong>Tableau dashboard</strong></td>\n<td>Semantic model (locked)</td>\n<td>Tableau only</td>\n<td>Limited</td>\n<td>Enterprise</td>\n<td>Extract or live</td>\n</tr>\n<tr>\n<td><strong>Looker dashboard</strong></td>\n<td>LookML</td>\n<td>Looker + embed</td>\n<td>No</td>\n<td>Enterprise</td>\n<td>PDTs</td>\n</tr>\n<tr>\n<td><strong>Metabase dashboard</strong></td>\n<td>None</td>\n<td>Metabase + embed</td>\n<td>No</td>\n<td>Enterprise only</td>\n<td>No caching</td>\n</tr>\n<tr>\n<td><strong>Grafana dashboard</strong></td>\n<td>None</td>\n<td>Grafana only</td>\n<td>No</td>\n<td>Basic</td>\n<td>Query-level</td>\n</tr>\n<tr>\n<td><strong>Superset dashboard</strong></td>\n<td>None</td>\n<td>Superset + embed</td>\n<td>No</td>\n<td>Limited</td>\n<td>No caching</td>\n</tr>\n<tr>\n<td><strong>Bonnard</strong></td>\n<td>Inherits your <code>runSql</code></td>\n<td>Any MCP Apps client</td>\n<td>MCP native (<code>visualize</code> tool)</td>\n<td>Inherits your <code>runSql</code></td>\n<td>Inherits your warehouse</td>\n</tr>\n</tbody>\n</table>\n<p>The traditional tools render KPIs inside the dashboard tool. Bonnard renders them as interactive charts via the MCP <code>visualize</code> tool, from query results, inside any MCP Apps client.</p>\n<h2 id=\"when-to-keep-your-kpi-dashboard\">When to keep your KPI dashboard</h2>\n<p>KPI dashboards aren’t always wrong. They work when:</p>\n<ul>\n<li>One team, one tool, one audience</li>\n<li>The metrics are simple and stable</li>\n<li>Nobody else needs the same numbers in a different format</li>\n<li>You don’t need AI agent access or embedded analytics</li>\n<li>The dashboard builder is still on the team and maintains it</li>\n</ul>\n<p>They break when:</p>\n<ul>\n<li>Multiple teams need the same KPIs in different tools</li>\n<li>Customers need to see their metrics inside your product</li>\n<li>AI agents need to query KPIs</li>\n<li>The dashboard shows different numbers than finance reports</li>\n<li>The data team spends more time maintaining dashboards than defining metrics</li>\n</ul>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>Add a <code>visualize</code> tool to your MCP server so an agent can render a KPI chart on demand:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>That registers the <code>visualize</code> tool on your server. Someone asks the agent for a KPI, your <code>runSql</code> returns the rows, and Bonnard renders an interactive chart inside Claude or ChatGPT. Adapters ship for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<p>Full walkthrough: <a href=\"/blog/mcp-charts\">Add Interactive Charts to Your MCP Server</a>. For the design choices behind the tool: <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">How Bonnard Builds Agent-Friendly MCPs</a>.</p>\n<p>Source is on <a href=\"https://github.com/bonnard-data/mcp-charts\">GitHub</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-a-kpi-dashboard-1\">What is a KPI dashboard?</h3>\n<p>A KPI dashboard is a visual display of key performance indicators: revenue, churn, active users, conversion rate, and other metrics that measure business health. Traditional KPI dashboards live in BI tools like Power BI, Tableau, Looker, or Grafana. Modern approaches define KPIs in a semantic layer and serve them across dashboards, AI agents, and embedded analytics simultaneously.</p>\n<h3 id=\"what-kpis-should-be-on-a-dashboard\">What KPIs should be on a dashboard?</h3>\n<p>The KPIs depend on your business. Common B2B SaaS KPIs: MRR, ARR, churn rate, net revenue retention, active users, customer count, NPS, average contract value. The specific metrics matter less than the governance: every KPI should have one canonical definition that every consumer references.</p>\n<h3 id=\"what-is-the-best-kpi-dashboard-tool\">What is the best KPI dashboard tool?</h3>\n<p>For internal-only dashboards: Power BI (Microsoft ecosystem), Tableau (Salesforce ecosystem), Looker (Google Cloud), or Metabase (open source). For governed KPIs served to multiple surfaces including AI agents and <a href=\"/glossary/embedded-analytics\">embedded analytics</a>: a semantic layer approach. The right choice depends on how many consumers need the same metrics.</p>\n<h3 id=\"how-do-i-make-my-kpi-dashboard-load-faster\">How do I make my KPI dashboard load faster?</h3>\n<p>Most dashboard slowness comes from querying the warehouse on every render. <a href=\"/glossary/pre-aggregation\">Pre-aggregation</a> caching pre-computes common KPI rollups. The dashboard queries the cache instead of the warehouse. Response times drop from seconds to single-digit milliseconds.</p>\n<h3 id=\"can-ai-agents-query-kpis\">Can AI agents query KPIs?</h3>\n<p>With a semantic layer and MCP support, yes. AI agents discover available KPIs via <code>explore_schema</code> and query them via <code>query</code>. The agent gets governed, multi-tenant data scoped to their access level. Without a semantic layer, agents generate ad-hoc SQL and return inconsistent numbers. See <a href=\"/blog/what-is-agentic-semantic-layer\">What Is an Agentic Semantic Layer?</a>.</p>\n<h3 id=\"what-is-the-difference-between-a-kpi-dashboard-and-analytics-dashboard\">What is the difference between a KPI dashboard and analytics dashboard?</h3>\n<p>A KPI dashboard focuses on a small set of headline metrics that measure business health. An analytics dashboard provides broader exploration: filtering, drilling, and ad-hoc querying. In practice, both benefit from governed metric definitions. The KPI dashboard shows the numbers. The analytics dashboard lets you investigate why they changed.</p>\n<h3 id=\"how-often-should-a-kpi-dashboard-refresh\">How often should a KPI dashboard refresh?</h3>\n<p>Depends on the KPI. Revenue and churn: daily or hourly. Active users and API metrics: real-time or near-real-time. Strategic KPIs (NPS, retention cohorts): weekly or monthly. With pre-aggregation, refresh frequency is configurable per metric. Set aggressive refresh for operational KPIs and relaxed refresh for strategic ones.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/real-time-analytics",
      "url": "https://bonnard.dev/blog/real-time-analytics",
      "title": "Real-Time Analytics: When You Need It and When You Don't",
      "summary": "Not every metric needs real-time data. Here's how to decide what needs sub-second freshness, what can be cached, and how pre-aggregation handles both.",
      "content_html": "<p>“We need real-time analytics” is one of the most common requests in data engineering. It’s also one of the most misunderstood. When the VP of Sales says “real-time,” they usually mean “faster than the dashboard that refreshes overnight.” When the CTO says it, they might mean sub-second event streaming. The gap between those two definitions is a 6-month infrastructure project.</p>\n<p>Most teams don’t need true real-time. They need fast enough. And “fast enough” is achievable with pre-aggregation caching at a fraction of the complexity and cost of a streaming architecture.</p>\n<h2 id=\"what-is-real-time-analytics\">What is real-time analytics?</h2>\n<p>Real-time analytics means querying data with minimal latency between when an event happens and when it’s visible in your analytics. The spectrum:</p>\n<table>\n<thead>\n<tr>\n<th>Freshness</th>\n<th>Latency</th>\n<th>Architecture</th>\n<th>Use case</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>True real-time</strong></td>\n<td>&lt; 1 second</td>\n<td>Event streaming (Kafka, Flink)</td>\n<td>Fraud detection, stock trading, live monitoring</td>\n</tr>\n<tr>\n<td><strong>Near real-time</strong></td>\n<td>1-60 seconds</td>\n<td>Micro-batch or streaming</td>\n<td>Operational dashboards, alerting</td>\n</tr>\n<tr>\n<td><strong>Frequent refresh</strong></td>\n<td>1-60 minutes</td>\n<td>Scheduled refresh + caching</td>\n<td>KPI dashboards, AI agent queries</td>\n</tr>\n<tr>\n<td><strong>Batch</strong></td>\n<td>Hours to daily</td>\n<td>Scheduled ETL</td>\n<td>Board reports, monthly summaries</td>\n</tr>\n</tbody>\n</table>\n<p>Most analytics use cases fall in the “frequent refresh” category. Revenue by region doesn’t need sub-second freshness. Active users in the last hour doesn’t need event streaming. A pre-aggregation cache that refreshes every 15 minutes covers 90% of what teams call “real-time.”</p>\n<h2 id=\"when-you-actually-need-real-time\">When you actually need real-time</h2>\n<p>True real-time analytics (sub-second latency from event to query result) is worth the infrastructure investment when:</p>\n<ul>\n<li><strong>Fraud detection.</strong> Every second of delay is potential fraud that slips through.</li>\n<li><strong>Live monitoring.</strong> Server health, API error rates, active user counts for live products.</li>\n<li><strong>Trading and pricing.</strong> Financial instruments where stale data means wrong prices.</li>\n<li><strong>Live events.</strong> Streaming metrics during a product launch, marketing campaign, or live broadcast.</li>\n</ul>\n<p>If you’re in one of these categories, you need an event streaming architecture: Kafka, Flink, Materialize, or similar. A semantic layer complements this by governing the metric definitions, but the freshness comes from the streaming pipeline.</p>\n<h2 id=\"when-you-dont-and-what-to-use-instead\">When you don’t (and what to use instead)</h2>\n<p>Most business analytics doesn’t need sub-second freshness. It needs:</p>\n<ol>\n<li><strong>Fast query response</strong> (milliseconds, not seconds)</li>\n<li><strong>Reasonably fresh data</strong> (minutes or hours, not days)</li>\n<li><strong>Consistent numbers</strong> (same answer everywhere)</li>\n</ol>\n<p><a href=\"/glossary/pre-aggregation\">Pre-aggregation</a> gives you all three without a streaming architecture.</p>\n<h3 id=\"how-pre-aggregation-works\">How pre-aggregation works</h3>\n<p>The <a href=\"/glossary/semantic-layer\">semantic layer</a> pre-computes your most common queries and caches the results. When a dashboard or AI agent asks for “revenue by region this month,” the query hits the cache instead of scanning raw warehouse tables.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#22863A\">    pre_aggregations</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">revenue_by_region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        time_dimension</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        granularity</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">day</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        refresh_key</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">          every</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">15 minutes</span></span></code></pre>\n<p>The cache rebuilds every 15 minutes. Queries that match the pre-aggregation return in single-digit milliseconds. Queries that don’t match fall through to the warehouse (seconds).</p>\n<p><strong>The result:</strong> your dashboard loads in under a second. Your AI agent gets responses in milliseconds. Your data is at most 15 minutes old. For 90% of analytics use cases, this is indistinguishable from “real-time.”</p>\n<h3 id=\"different-refresh-rates-for-different-metrics\">Different refresh rates for different metrics</h3>\n<p>Not all metrics need the same freshness. Configure per rollup:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">    pre_aggregations</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Operational: refresh every 5 minutes</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">active_users_live</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        measures</span><span style=\"color:#24292E\">: [</span><span style=\"color:#032F62\">active_users</span><span style=\"color:#24292E\">]</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        time_dimension</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">last_seen_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        granularity</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">minute</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        refresh_key</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">          every</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">5 minutes</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Strategic: refresh every hour</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">daily_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        measures</span><span style=\"color:#24292E\">: [</span><span style=\"color:#032F62\">total_revenue</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">mrr</span><span style=\"color:#24292E\">]</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        dimensions</span><span style=\"color:#24292E\">: [</span><span style=\"color:#032F62\">plan</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">region</span><span style=\"color:#24292E\">]</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        time_dimension</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        granularity</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">day</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        refresh_key</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">          every</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">1 hour</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Historical: refresh daily</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">monthly_cohorts</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        measures</span><span style=\"color:#24292E\">: [</span><span style=\"color:#032F62\">retention_rate</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">ltv</span><span style=\"color:#24292E\">]</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        dimensions</span><span style=\"color:#24292E\">: [</span><span style=\"color:#032F62\">cohort_month</span><span style=\"color:#24292E\">, </span><span style=\"color:#032F62\">plan</span><span style=\"color:#24292E\">]</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        time_dimension</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        granularity</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">month</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        refresh_key</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">          every</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">24 hours</span></span></code></pre>\n<p>Operational metrics refresh every 5 minutes. Revenue refreshes hourly. Historical cohorts refresh daily. Each metric gets the freshness it needs without over-engineering.</p>\n<h2 id=\"real-time-analytics-for-b2b-products\">Real-time analytics for B2B products</h2>\n<p>If you’re shipping analytics to B2B customers, “real-time” has a different meaning. Your customers expect their dashboards to load fast and show recent data. They don’t expect sub-second event streaming.</p>\n<p>Pre-aggregation handles this well:</p>\n<ul>\n<li><strong>Fast load times.</strong> Cached queries return in milliseconds. Customer dashboards feel instant.</li>\n<li><strong>Multi-tenant caching.</strong> The cache respects tenant isolation. Customer A’s cached rollup doesn’t include Customer B’s data. Security context filters apply at cache build time.</li>\n<li><strong>Cost control.</strong> On usage-based warehouses (<a href=\"/integrations/bigquery\">BigQuery</a>, <a href=\"/integrations/snowflake\">Snowflake</a>), fewer raw queries means lower costs. Serving from cache is essentially free.</li>\n<li><strong>AI agent compatibility.</strong> Agents make multiple queries per interaction. Pre-aggregation keeps response times low even with agent-scale query volumes.</li>\n</ul>\n<h2 id=\"real-time-analytics-architecture-compared\">Real-time analytics architecture compared</h2>\n<table>\n<thead>\n<tr>\n<th>Approach</th>\n<th>Freshness</th>\n<th>Query speed</th>\n<th>Complexity</th>\n<th>Cost</th>\n<th>Best for</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Event streaming</strong> (Kafka + Flink)</td>\n<td>Sub-second</td>\n<td>Fast (materialized views)</td>\n<td>Very high</td>\n<td>High</td>\n<td>Fraud, trading, live monitoring</td>\n</tr>\n<tr>\n<td><strong>Streaming DB</strong> (Materialize, RisingWave)</td>\n<td>Seconds</td>\n<td>Fast</td>\n<td>High</td>\n<td>Medium-high</td>\n<td>Continuous queries, CDC</td>\n</tr>\n<tr>\n<td><strong>Warehouse + pre-aggregation</strong></td>\n<td>Minutes</td>\n<td>Milliseconds (cached)</td>\n<td>Low</td>\n<td>Low-medium</td>\n<td>Dashboards, AI agents, embedded analytics</td>\n</tr>\n<tr>\n<td><strong>Warehouse direct</strong></td>\n<td>Minutes-hours (ETL dependent)</td>\n<td>Seconds</td>\n<td>Low</td>\n<td>Medium</td>\n<td>Ad-hoc analysis, batch reports</td>\n</tr>\n<tr>\n<td><strong>Spreadsheet</strong></td>\n<td>Manual</td>\n<td>N/A</td>\n<td>None</td>\n<td>Free</td>\n<td>One-off analysis</td>\n</tr>\n</tbody>\n</table>\n<p>For most teams, the warehouse + pre-aggregation approach covers the use case. Add streaming only for the metrics that genuinely need sub-second freshness.</p>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>Once your queries return fast enough, agents become a consumer, and an agent that fetches rows still has to chart them. Add a <code>visualize</code> tool to your MCP server so it can:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>That registers the <code>visualize</code> tool on your server. The agent calls it with a query, your <code>runSql</code> returns the rows (from a cached rollup or the warehouse), and Bonnard renders an interactive chart inside Claude or ChatGPT. Adapters ship for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>.</p>\n<p>Full walkthrough: <a href=\"/blog/mcp-charts\">Add Interactive Charts to Your MCP Server</a>. For the design choices behind the tool: <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">How Bonnard Builds Agent-Friendly MCPs</a>.</p>\n<p>Source is on <a href=\"https://github.com/bonnard-data/mcp-charts\">GitHub</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-real-time-analytics-1\">What is real-time analytics?</h3>\n<p>Real-time analytics means querying data with minimal delay between when events happen and when they appear in your analytics. True real-time (sub-second) requires event streaming. Near-real-time (minutes) is achievable with pre-aggregation caching on top of a data warehouse.</p>\n<h3 id=\"do-i-need-real-time-analytics\">Do I need real-time analytics?</h3>\n<p>Most business analytics doesn’t need sub-second freshness. If your use case is dashboards, AI agent queries, or <a href=\"/glossary/embedded-analytics\">embedded analytics</a>, a pre-aggregation cache refreshing every 5-60 minutes provides “real-time” performance at a fraction of the streaming complexity. True real-time is needed for fraud detection, live monitoring, and financial trading.</p>\n<h3 id=\"what-is-the-difference-between-real-time-and-batch-analytics\">What is the difference between real-time and batch analytics?</h3>\n<p>Batch analytics processes data on a schedule (hourly, daily). Real-time analytics processes data continuously with minimal delay. Pre-aggregation caching sits in between: the cache rebuilds on a schedule (every N minutes), but queries against the cache return in milliseconds. It’s batch refresh with real-time query performance.</p>\n<h3 id=\"how-does-pre-aggregation-make-analytics-feel-real-time\">How does pre-aggregation make analytics feel real-time?</h3>\n<p>Pre-aggregation pre-computes your most common queries and caches the results. When a user or AI agent queries “revenue by region,” the response comes from the cache in single-digit milliseconds instead of scanning raw warehouse tables (which takes seconds). The data is minutes old, but the query feels instant.</p>\n<h3 id=\"what-is-streaming-analytics\">What is streaming analytics?</h3>\n<p>Streaming analytics processes events as they arrive, maintaining continuously updated query results. Technologies include Kafka, Flink, Materialize, and RisingWave. It’s the right choice for sub-second freshness requirements. For most business analytics, pre-aggregation caching provides comparable user experience at lower cost and complexity.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/self-service-bi",
      "url": "https://bonnard.dev/blog/self-service-bi",
      "title": "Self-Service BI Is a Lie (Unless You Govern the Metrics)",
      "summary": "Self-service BI for customers promised to free the data team. Instead it created metric chaos. The category, the tradeoffs, and where AI agent charts fit.",
      "content_html": "<p>Self-service BI was supposed to free the data team. Give everyone a BI tool, teach them to drag and drop, and they’ll answer their own questions. The data team can stop building dashboards and focus on infrastructure.</p>\n<p>That’s not what happened.</p>\n<p>What happened: everyone builds their own dashboards with their own metric definitions. Marketing’s “active users” counts monthly logins. Product’s “active users” counts weekly feature usage. Finance’s “active users” counts paying customers. Three dashboards, three numbers, one term. The data team now spends their time reconciling conflicting metrics instead of building.</p>\n<p>Self-service BI without governed metrics is just self-service chaos.</p>\n<h2 id=\"what-is-self-service-bi\">What is self-service BI?</h2>\n<p>Self-service BI means non-technical users can query data, build visualizations, and generate reports without help from the data team. The tools (Metabase, Looker, Power BI, Tableau, Superset) provide drag-and-drop interfaces, visual query builders, and template libraries.</p>\n<p>The promise: democratize data access. Anyone can answer their own questions.</p>\n<p>The reality: it works for simple questions (“how many orders this week?”) and breaks for anything requiring business logic (“what’s our net revenue retention?”). Users either define metrics incorrectly or ask the data team anyway. The data team becomes a help desk for the self-service tool instead of a help desk for SQL.</p>\n<h2 id=\"where-self-service-bi-goes-wrong\">Where self-service BI goes wrong</h2>\n<h3 id=\"metric-sprawl\">Metric sprawl</h3>\n<p>Without a central definition, every self-service user creates their own version of key metrics. A company with 50 Metabase users might have 30 different “revenue” calculations saved across collections. Which one is right? The one that matches the board report. Which one matches the board report? Nobody knows without checking each one.</p>\n<h3 id=\"the-someone-who-knows-bottleneck\">The “someone who knows” bottleneck</h3>\n<p>True self-service requires understanding the data model. Which table has revenue? What does <code>status = 3</code> mean? Is the <code>amount</code> column in cents or dollars? Pre-tax or post-tax? Including shipping or excluding?</p>\n<p>Without this context, self-service users either guess (and get wrong answers) or ask the data team (and it’s not self-service). The BI tool gives access to data. It doesn’t give understanding of what the data means.</p>\n<h3 id=\"dashboard-proliferation\">Dashboard proliferation</h3>\n<p>Every self-service user creates dashboards for their use case. The dashboard count grows from 10 to 500. Most are stale. Many are duplicates. Finding the right dashboard becomes its own research project. The data team periodically “cleans up,” deleting abandoned dashboards and breaking someone’s workflow each time.</p>\n<h3 id=\"open-source-bi-tools-and-their-limitations\">Open source BI tools and their limitations</h3>\n<p>Open-source BI tools make self-service accessible without enterprise licensing. The main options:</p>\n<ul>\n<li><strong><a href=\"/vs-metabase\">Metabase</a></strong> (AGPL): Most popular. Visual query builder, drag-and-drop dashboards. No semantic layer. Multi-tenancy requires Enterprise license.</li>\n<li><strong><a href=\"/vs-superset\">Superset</a></strong> (Apache 2.0): SQL-native, powerful for technical teams. No semantic layer. Limited multi-tenancy.</li>\n<li><strong>Redash</strong> (BSD): SQL editor focused. Simple, lightweight. No governance features. Maintenance mode since Databricks acquired it.</li>\n<li><strong>Lightdash</strong> (MIT): dbt-native. Good for teams already using dbt. Growing community. No MCP or embedded SDK.</li>\n<li><strong>Evidence</strong> (Apache 2.0): Code-first reporting. Markdown + SQL. Good for scheduled reports. Not designed for embedded or multi-tenant use cases.</li>\n</ul>\n<p>They share the same fundamental limitation: no <a href=\"/glossary/semantic-layer\">semantic layer</a>. Users query raw tables and define metrics ad-hoc. The open-source tools democratize access. They don’t democratize understanding.</p>\n<h2 id=\"what-real-self-service-looks-like\">What real self-service looks like</h2>\n<p>Real self-service means anyone can query data and get the right answer. Not “anyone can query data and get an answer.” The difference is governance.</p>\n<h3 id=\"the-data-team-defines-metrics\">The data team defines metrics</h3>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">analytics.revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' AND type != 'trial' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"Finance-approved revenue definition (excludes refunds and trials)\"</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">net_revenue_retention</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"current_period_mrr / prior_period_mrr\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">avg</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"NRR for existing customer cohorts\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">plan</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">plan_name</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">region</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">period</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">period_date</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span></code></pre>\n<p>These definitions live in Git. Finance approves them in a pull request. When the definition changes, every consumer gets the update. There’s one “revenue.” Not 30.</p>\n<h3 id=\"everyone-queries-the-same-definitions\">Everyone queries the same definitions</h3>\n<p>The product manager asks an AI agent: “What’s revenue by plan this quarter?” The agent calls <code>explore_schema</code>, finds <code>revenue.total_revenue</code>, and queries it. Same definition finance approved.</p>\n<p>The customer success team views an <a href=\"/glossary/embedded-analytics\">embedded analytics</a> dashboard in the product. Same definition.</p>\n<p>The CEO checks a markdown dashboard. Same definition.</p>\n<p>Nobody re-implements the metric. Nobody guesses which table to query. The data team defined it once. Every surface serves it.</p>\n<h3 id=\"the-data-team-builds-not-debugs\">The data team builds, not debugs</h3>\n<p>With governed metrics, the data team’s job shifts from “answer ad-hoc questions” and “reconcile conflicting dashboards” to “define and maintain metric definitions.” They add new metrics, refine existing ones, and optimize <a href=\"/glossary/pre-aggregation\">pre-aggregation</a> strategies. The mechanical work (querying, formatting, presenting) is handled by the semantic layer and its consumers.</p>\n<h2 id=\"self-service-bi-tools-compared\">Self-service BI tools compared</h2>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Self-service model</th>\n<th>Governed metrics</th>\n<th>AI agent support</th>\n<th>Open source</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Metabase</strong></td>\n<td>Visual query builder</td>\n<td>No</td>\n<td>No</td>\n<td>Yes (AGPL)</td>\n</tr>\n<tr>\n<td><strong>Superset</strong></td>\n<td>SQL + visual builder</td>\n<td>No</td>\n<td>No</td>\n<td>Yes (Apache 2.0)</td>\n</tr>\n<tr>\n<td><strong>Redash</strong></td>\n<td>SQL editor</td>\n<td>No</td>\n<td>No</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td><strong>Power BI</strong></td>\n<td>Drag-and-drop</td>\n<td>DAX (PBI only)</td>\n<td>Copilot</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Tableau</strong></td>\n<td>Drag-and-drop</td>\n<td>Tableau model</td>\n<td>Limited</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Looker</strong></td>\n<td>LookML + Explore</td>\n<td>LookML</td>\n<td>No</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>ThoughtSpot</strong></td>\n<td>Natural language search</td>\n<td>Proprietary</td>\n<td>Proprietary</td>\n<td>No</td>\n</tr>\n<tr>\n<td><strong>Charts in AI agent (Bonnard)</strong></td>\n<td>Charts inside Claude or ChatGPT via MCP</td>\n<td>N/A (your <code>runSql</code>)</td>\n<td>Native (<code>visualize</code> tool)</td>\n<td>Yes (npm)</td>\n</tr>\n</tbody>\n</table>\n<p>The tools in the top half give users direct data access with no metric governance. The tools in the bottom half add governance but lock it to their ecosystem. The bottom row is a different kind of option: <code>@bonnard/mcp-charts</code> does not replace a BI tool. It adds a <code>visualize</code> tool to your MCP server so customers get interactive charts inside Claude or ChatGPT, rendered from your own query results.</p>\n<h2 id=\"when-traditional-self-service-bi-is-enough\">When traditional self-service BI is enough</h2>\n<p>Self-service BI works when:</p>\n<ul>\n<li>A small team (&lt; 10 analysts) agrees on metric definitions informally</li>\n<li>One BI tool serves all consumers</li>\n<li>Internal use only (no customers seeing the numbers)</li>\n<li>The data model is simple enough that users understand it</li>\n<li>You have someone who maintains a “source of truth” dashboard that others copy</li>\n</ul>\n<p>It breaks when:</p>\n<ul>\n<li>Multiple teams define the same metric differently</li>\n<li>You need metrics in dashboards AND APIs AND AI agents AND embedded analytics</li>\n<li>Customers see the numbers (trust and consistency matter)</li>\n<li>The data team spends more time reconciling than building</li>\n<li>New hires take weeks to find the right dashboard</li>\n</ul>\n<h2 id=\"self-service-for-customers-inside-the-agent\">Self-service for customers, inside the agent</h2>\n<p>Governed metrics fix the consistency problem for internal self-service. There’s a separate question for customer-facing self-service: where does the customer ask their question? Increasingly the answer is an AI agent. They want to ask about their data in Claude or ChatGPT and get a chart back.</p>\n<p><code>@bonnard/mcp-charts</code> covers that surface. It is not a BI tool or a dashboard product. It is a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results, inside the agent.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"typescript\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, {</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">  runSql</span><span style=\"color:#24292E\">: </span><span style=\"color:#D73A49\">async</span><span style=\"color:#24292E\"> (</span><span style=\"color:#E36209\">sql</span><span style=\"color:#24292E\">) </span><span style=\"color:#D73A49\">=></span><span style=\"color:#24292E\"> db.</span><span style=\"color:#6F42C1\">query</span><span style=\"color:#24292E\">(sql),</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">});</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool. The agent calls <code>visualize</code>, your <code>runSql</code> returns rows, Bonnard infers the chart from the typed result, and it renders an interactive <code>ui://</code> widget in Claude or ChatGPT. Chart types: line, bar, area, pie, scatter, funnel, waterfall, and table. Native adapters cover Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>. Bonnard never touches your database.</p>\n<p>For background: <a href=\"/blog/what-is-a-semantic-layer\">What Is a Semantic Layer?</a>. For the full walkthrough: <a href=\"/blog/mcp-charts\">MCP charts</a>.</p>\n<p>Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-self-service-bi-1\">What is self-service BI?</h3>\n<p>Self-service BI is a BI model where non-technical users can query data, build visualizations, and generate reports without help from the data team. Tools like Metabase, Power BI, and Tableau provide drag-and-drop interfaces for this. The limitation: self-service without governed metrics leads to inconsistent numbers across the organization.</p>\n<h3 id=\"what-is-the-best-open-source-bi-tool\">What is the best open-source BI tool?</h3>\n<p>For internal dashboards: <a href=\"/vs-metabase\">Metabase</a> (most popular), Superset (more technical), or Redash (SQL-focused). For governed metrics served to multiple surfaces including AI agents: a semantic layer approach. Open-source BI tools give you self-service querying. A semantic layer gives you self-service with correct, governed answers.</p>\n<h3 id=\"what-is-the-difference-between-self-service-bi-and-a-semantic-layer\">What is the difference between self-service BI and a semantic layer?</h3>\n<p>Self-service BI gives users tools to query data. A semantic layer defines what the data means. They’re complementary: a self-service BI tool with a semantic layer underneath gives users access to governed metrics. Without the semantic layer, self-service users define metrics ad-hoc and the numbers diverge.</p>\n<h3 id=\"how-do-i-prevent-metric-chaos-in-self-service-bi\">How do I prevent metric chaos in self-service BI?</h3>\n<p>Define each metric once in a <a href=\"/glossary/semantic-layer\">semantic layer</a>. Version the definitions in Git. Review changes in pull requests. Serve the same definitions to every consumer. When everyone queries the same governed definitions, there’s nothing to reconcile.</p>\n<h3 id=\"how-do-i-offer-self-service-bi-for-customers-in-2026\">How do I offer self-service BI for customers in 2026?</h3>\n<p>Decide where customers ask their questions. A traditional embedded BI tool puts a dashboard in your product. The newer surface is the AI agent: customers ask in Claude or ChatGPT and get a chart back. For that surface, <code>@bonnard/mcp-charts</code> adds a <code>visualize</code> tool to your MCP server that renders interactive charts from your query results. The two are complementary, not substitutes. Install with <code>npm install @bonnard/mcp-charts</code>.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/what-is-a-semantic-layer",
      "url": "https://bonnard.dev/blog/what-is-a-semantic-layer",
      "title": "What Is a Semantic Layer? Guide for Data Engineers",
      "summary": "A semantic layer defines business metrics once so every consumer gets the same answer, including AI agents. How it works, with code examples.",
      "content_html": "<p>Your data warehouse has a table called <code>orders</code>. It has columns like <code>amount</code>, <code>status</code>, <code>created_at</code>, and <code>customer_id</code>. Now three people ask “What was Q1 revenue?”</p>\n<p>The analyst writes <code>SELECT SUM(amount) FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-03-31'</code>. The data engineer adds <code>WHERE status = 'completed'</code>. Finance excludes refunds and trial conversions. Three queries, three numbers, one question. Nobody is wrong. They just defined “revenue” differently.</p>\n<p>Multiply this by every metric in your organization, every team that queries the warehouse, and every tool that displays a number. That’s the problem.</p>\n<p>A semantic layer solves it by defining each metric once, in one place, and serving that definition to every consumer.</p>\n<h2 id=\"what-is-a-semantic-layer\">What is a semantic layer?</h2>\n<p>A semantic layer is a metadata layer between your data warehouse and every tool that queries it. It defines business metrics, maps them to SQL, and exposes them through APIs. Instead of every consumer writing its own query, they all reference the same definition.</p>\n<p>When someone asks for “revenue,” the semantic layer knows that means:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"sql\"><code><span class=\"line\"><span style=\"color:#005CC5\">SUM</span><span style=\"color:#24292E\">(</span><span style=\"color:#D73A49\">CASE</span><span style=\"color:#D73A49\"> WHEN</span><span style=\"color:#D73A49\"> status</span><span style=\"color:#D73A49\"> !=</span><span style=\"color:#032F62\"> 'refunded'</span><span style=\"color:#D73A49\"> AND</span><span style=\"color:#D73A49\"> type</span><span style=\"color:#D73A49\"> !=</span><span style=\"color:#032F62\"> 'trial'</span><span style=\"color:#D73A49\"> THEN</span><span style=\"color:#24292E\"> amount </span><span style=\"color:#D73A49\">ELSE</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> END</span><span style=\"color:#24292E\">)</span></span></code></pre>\n<p>That definition lives in one place. Dashboards, APIs, AI agents, and ad-hoc queries all use it. Change the definition once and every consumer gets the updated calculation. No Slack thread asking “which number is right.” No detective work tracing a wrong number back to a stale query in a notebook somewhere.</p>\n<p>The core components of a semantic layer:</p>\n<ul>\n<li><strong>Metrics (measures).</strong> The numbers you aggregate: revenue, order count, average deal size. Each metric has a fixed SQL definition.</li>\n<li><strong>Dimensions.</strong> The columns you filter and group by: date, status, category, region. Dimensions define the axes of analysis.</li>\n<li><strong>Relationships (joins).</strong> How tables connect: orders belong to customers, products belong to categories. Defined once, reused by every query.</li>\n<li><strong>Access rules.</strong> Who can see what. Row-level security, tenant isolation, role-based access. Enforced on every query, not bolted on per tool.</li>\n<li><strong>Caching rules.</strong> Which rollups to pre-compute, how often to refresh, when to invalidate. Performance is part of the definition.</li>\n</ul>\n<h2 id=\"a-brief-history\">A brief history</h2>\n<p>The idea of abstracting business logic from raw data isn’t new.</p>\n<p>In the 1990s, <strong>Business Objects</strong> introduced “universes,” metadata layers that mapped business terms to database columns. Analysts queried “Revenue by Region” without knowing the underlying table structure. It worked, but only within Business Objects.</p>\n<p><strong>SSAS (SQL Server Analysis Services)</strong> cubes brought multidimensional modeling to the Microsoft stack. You defined measures and dimensions in a cube, and every Excel pivot table and SSRS report consumed the same definitions. Powerful, but tightly coupled to the Microsoft ecosystem.</p>\n<p><strong>Looker’s LookML</strong> (2012) moved semantic modeling into code. Metric definitions lived in version-controlled files, not a GUI. This was a significant shift: data teams could review metric changes in pull requests, not point-and-click editors. But LookML was (and is) proprietary to Looker, which is now proprietary to Google Cloud.</p>\n<p><strong>dbt’s MetricFlow</strong> (2023) brought metric definitions into the dbt ecosystem. Define metrics alongside your transformations. Good idea, but MetricFlow defines metrics without serving them: no caching, no multi-tenancy, no API layer.</p>\n<p>The current generation of semantic layers (Cube, AtScale) are standalone infrastructure. They connect to any warehouse, serve any consumer, and run independently of your BI tool. The semantic layer is no longer a feature inside a product. It’s a layer in the stack.</p>\n<p>What’s driving this shift: the number of consumers has exploded. In 2015, a semantic layer served dashboards. In 2026, it also serves <a href=\"/glossary/mcp\">AI agents via MCP</a>. One definition needs to serve all of them, and the newest consumer is an agent that queries metrics and then wants to chart the result in the conversation. A BI-embedded semantic layer can’t reach that consumer at all.</p>\n<h2 id=\"why-does-it-matter\">Why does it matter?</h2>\n<p>Without a semantic layer, business logic lives in three places: your analysts’ heads, scattered SQL files, and buried dashboard definitions. Each copy drifts over time. Someone updates the revenue definition in Looker but forgets the dbt model. A new analyst writes a fresh query from scratch and gets a number that doesn’t match either.</p>\n<p>The cost isn’t abstract:</p>\n<p><strong>Inconsistent metrics.</strong> Marketing reports 12% growth. Finance reports 8%. The board meeting stalls while someone debugs which number is right. This happens every quarter at companies without a shared metric layer.</p>\n<p><strong>Duplicated logic.</strong> Every new dashboard, API endpoint, or report re-implements the same calculations. You end up with revenue defined in 14 places, each slightly different, each maintained by a different person. One team discovers a bug in their version and fixes it. The other 13 don’t.</p>\n<p><strong>Slow onboarding.</strong> New data engineers spend weeks learning which queries are canonical and which are stale. “Ask Sarah, she knows which revenue query is the right one.” Tribal knowledge doesn’t scale.</p>\n<p><strong>The data team becomes a service desk.</strong> Instead of building, they spend their time answering ad-hoc questions: “Can you pull revenue by region for Q4?” “Which dashboard has the right churn number?” “Why does this report show different numbers than that one?” A semantic layer makes the data self-serve. The team defines the metrics. Everyone else queries them.</p>\n<p><strong>AI agents make it worse.</strong> An AI agent with warehouse access generates plausible SQL from column names. It doesn’t know your business rules. Ask two different agents the same question and you’ll get two different numbers. Neither matches finance. <a href=\"/glossary/text-to-sql\">Text-to-SQL</a> gives you speed without trust. A semantic layer gives you both.</p>\n<h2 id=\"types-of-semantic-layers\">Types of semantic layers</h2>\n<p>Not all semantic layers work the same way. The architecture matters because it determines what consumers you can serve, how fast queries run, and how much you control.</p>\n<p><strong>Embedded semantic layers</strong> live inside a BI tool. Looker’s LookML, Tableau’s semantic model, Power BI’s DAX measures. They define metrics well, but only for that tool’s consumers. Your Looker semantic layer doesn’t help your React app or your AI agent. If you’re in one BI tool and staying there, this can be enough. Most teams outgrow it.</p>\n<p><strong>Virtual semantic layers</strong> sit between the warehouse and consumers without moving data. AtScale is the primary example. Queries pass through the semantic layer, which translates them to the right SQL dialect and sends them to the warehouse. No data duplication. The tradeoff: every query hits the warehouse, so latency depends on warehouse performance. Virtual layers add caching to mitigate this, but it’s an optimization, not the default.</p>\n<p><strong>Materialized semantic layers</strong> pre-compute rollups and store them separately. Cube’s pre-aggregation engine is the best-known example. You define which combinations of measures and dimensions to pre-compute, and the semantic layer builds and maintains those materialized tables. Hot queries hit the cache (single-digit milliseconds). Cold queries fall through to the warehouse. This is how you get sub-second performance at scale.</p>\n<p><strong>Hybrid semantic layers</strong> combine virtual and materialized approaches. Most modern semantic layers work this way. Define pre-aggregations for your most-queried metrics. Let everything else pass through to the warehouse. You configure the tradeoff per metric based on query frequency and latency requirements.</p>\n<p>For most teams, hybrid is the right choice. You get the flexibility of virtual queries with the performance of materialized caching where it matters.</p>\n<h2 id=\"how-it-works-in-practice\">How it works in practice</h2>\n<p>Modern semantic layers are defined in code (typically YAML), version-controlled in Git, and deployed as an API. Here’s what that looks like end to end.</p>\n<h3 id=\"define-your-metrics\">Define your metrics</h3>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' AND type != 'trial' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">average_order_value</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">amount</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">avg</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">customer_id</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">customer_id</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span></code></pre>\n<p><strong>Measures</strong> are the numbers you aggregate: revenue, count, average. <strong>Dimensions</strong> are the columns you filter and group by: status, date, category. <code>total_revenue</code> is now a governed definition, not a column someone interprets.</p>\n<p>You define joins between cubes to model relationships:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">customers</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.customers</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    joins</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        relationship</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">one_to_many</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"{customers.id} = {orders.customer_id}\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">plan</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">plan</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">signed_up_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span></code></pre>\n<p>Now any consumer can query “revenue by customer plan” without knowing how the tables join. The semantic layer handles the SQL generation.</p>\n<h3 id=\"add-caching-with-pre-aggregations\">Add caching with pre-aggregations</h3>\n<p>Queries against raw warehouse tables are expensive, especially when AI agents are querying them frequently. A <a href=\"/glossary/pre-aggregation\">pre-aggregation</a> caches rollups so consumers get sub-second responses instead of waiting for full table scans.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">    pre_aggregations</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">daily_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        time_dimension</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        granularity</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">day</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        refresh_key</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">          every</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">1 hour</span></span></code></pre>\n<p>The semantic layer rebuilds this rollup every hour. Hot queries hit the cache. Cold queries fall through to the warehouse. You configure the tradeoff per metric: high-traffic metrics get aggressive caching, low-traffic metrics query the warehouse directly.</p>\n<h3 id=\"define-access-control\">Define access control</h3>\n<p>For B2B products serving multiple customers, every query needs to be scoped to the right tenant. A security context enforces this structurally, not through prompt engineering or middleware hacks.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">    security_context</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">tenant_filter</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"{SECURITY_CONTEXT.tenant_id} = customer_id\"</span></span></code></pre>\n<p>Every query for a given tenant automatically includes this filter. It’s not optional. The consumer can’t skip it or override it. This is how <a href=\"/glossary/rbac\">RBAC</a> works in a semantic layer: defined in the schema, enforced on every query. It doesn’t matter if the consumer is a dashboard, an API call, or an AI agent. The access rules are the same.</p>\n<h3 id=\"expose-to-consumers\">Expose to consumers</h3>\n<p>One set of definitions, multiple consumers. A BI dashboard queries via REST API. A backend service calls an SDK. An AI agent discovers and queries metrics over <a href=\"/glossary/mcp\">MCP</a>. They all hit the same definitions and get the same numbers. That’s the point.</p>\n<p>The agent is the consumer that changed the most. It queries a metric, then wants to show the result, not as a wall of numbers but as a chart in the conversation. That’s where <a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>@bonnard/mcp-charts</code></a> comes in: a <code>visualize</code> tool you add to your MCP server that renders interactive charts from your query results, right in Claude or ChatGPT.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// rows come back from your warehouse, the chart renders in the client</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>The agent calls <code>visualize</code> with a query, your <code>runSql</code> returns the rows, Bonnard infers the chart from the typed result, and an interactive widget renders in the host. You write no frontend code, and Bonnard never touches your database. More in the <a href=\"/blog/mcp-charts\">MCP Charts guide</a>.</p>\n<h2 id=\"real-world-use-cases\">Real-world use cases</h2>\n<h3 id=\"customer-facing-analytics-in-a-b2b-saas-product\">Customer-facing analytics in a B2B SaaS product</h3>\n<p>You’re building a SaaS product and your customers want to see their data. Usage metrics, billing summaries, performance dashboards. Without a semantic layer, you build custom API endpoints for each view, maintain the SQL yourself, and hope the numbers match what your internal dashboards show.</p>\n<p>With a semantic layer: define the metrics once with row-level security, and let customers query their data through your product’s UI or through their own AI agents (via MCP). Multi-tenancy is structural. The same metric definition serves every customer with automatic data isolation.</p>\n<h3 id=\"replacing-internal-dashboards-nobody-opens\">Replacing internal dashboards nobody opens</h3>\n<p>Your team built 47 Metabase dashboards. Three of them get used. The rest are stale, broken, or duplicated. The data team spends time maintaining dashboard infrastructure instead of defining the metrics that matter.</p>\n<p>A semantic layer shifts the model: the data team defines and governs metrics. Consumers choose their own surface. Some use dashboards. Some query the API. Some ask an AI agent. The data team’s job is the definitions, not the delivery mechanism.</p>\n<h3 id=\"connecting-ai-agents-to-governed-data\">Connecting AI agents to governed data</h3>\n<p>Your engineering team wants to ship an AI-powered analytics feature. The first prototype uses text-to-SQL. It works in demos and breaks in production: inconsistent numbers, no tenant isolation, no audit trail. The fix isn’t better prompts. It’s a semantic layer. The agent queries metric definitions instead of raw tables. Every answer traces back to a versioned, governed definition. Full walkthrough: <a href=\"/blog/connect-ai-agent-data-warehouse\">How to Connect an AI Agent to Your Data Warehouse</a>.</p>\n<h2 id=\"semantic-layer-vs-dbt-metrics\">Semantic layer vs. dbt metrics</h2>\n<p>This is the most common question in the space, and the answer is: they’re complementary, not competing. But the differences matter.</p>\n<p><strong>dbt</strong> defines transformations: how raw data becomes clean tables. It runs SQL models that create <code>dim_customers</code>, <code>fct_orders</code>, and other modeled tables in your warehouse. This is the “T” in ELT. dbt is excellent at this.</p>\n<p><strong>dbt’s MetricFlow</strong> adds metric definitions on top of those tables. You can define <code>revenue</code> as a metric in your dbt project. This is genuinely useful for documentation and consistency within dbt’s ecosystem.</p>\n<p><strong>Where dbt stops:</strong> MetricFlow defines metrics but doesn’t serve them to production consumers. There’s no built-in:</p>\n<ul>\n<li><strong>Caching or pre-aggregation.</strong> Every query hits the warehouse.</li>\n<li><strong>Multi-tenancy.</strong> No per-tenant isolation for B2B use cases.</li>\n<li><strong>API serving.</strong> No REST API or SDK for applications to query.</li>\n<li><strong>AI agent integration.</strong> No MCP server or tool-use interface.</li>\n<li><strong>Row-level security.</strong> Access control is warehouse-level, not metric-level.</li>\n</ul>\n<p>A standalone semantic layer fills these gaps. It connects to the same warehouse where dbt outputs its modeled tables, defines metrics on top, and serves them with caching, multi-tenancy, and multiple APIs.</p>\n<p>The practical workflow: dbt transforms your raw data into clean tables on a schedule. The semantic layer defines business metrics on those tables and serves them to dashboards, applications, and AI agents in real time. Many semantic layers can import dbt models directly. You don’t choose between them. You use both.</p>\n<h2 id=\"semantic-layer-tools-compared\">Semantic layer tools compared</h2>\n<p>Several tools occupy this space, each with different design priorities.</p>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Approach</th>\n<th>Strengths</th>\n<th>Limitations</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong><a href=\"https://cube.dev\">Cube</a></strong></td>\n<td>Open-source semantic layer engine</td>\n<td>Battle-tested query engine, pre-aggregation, REST API, large community</td>\n<td>No built-in MCP, dashboards, or multi-tenant keys. You build the product layer yourself.</td>\n</tr>\n<tr>\n<td><strong>dbt Semantic Layer / MetricFlow</strong></td>\n<td>Metric definitions in dbt</td>\n<td>Tight dbt integration, SQL-native, good for documentation</td>\n<td>Defines metrics but doesn’t serve them. No caching, no multi-tenancy, no agent support.</td>\n</tr>\n<tr>\n<td><strong>Looker (LookML)</strong></td>\n<td>BI-embedded semantic layer</td>\n<td>Mature modeling language, strong governance, Google Cloud integration</td>\n<td>Expensive, enterprise-only, proprietary. Definitions locked to Looker’s ecosystem. No MCP.</td>\n</tr>\n<tr>\n<td><strong>AtScale</strong></td>\n<td>Virtual semantic layer</td>\n<td>Enterprise BI compatibility, no data movement, MDX/DAX support</td>\n<td>Enterprise pricing. Not agent-native. Complex deployment.</td>\n</tr>\n<tr>\n<td><strong>Power BI (DAX)</strong></td>\n<td>BI-embedded semantic layer</td>\n<td>Deep Microsoft integration, familiar to analysts</td>\n<td>Locked to Power BI ecosystem. No API serving. No multi-tenancy for embedded use cases.</td>\n</tr>\n<tr>\n<td><strong>Tableau (Semantic Model)</strong></td>\n<td>BI-embedded semantic layer</td>\n<td>Visual modeling, strong visualization</td>\n<td>Definitions don’t leave Tableau. No API access. Salesforce pricing.</td>\n</tr>\n<tr>\n<td><strong><a href=\"https://bonnard.dev\">Bonnard</a></strong></td>\n<td>Interactive charts via the MCP <code>visualize</code> tool</td>\n<td><code>@bonnard/mcp-charts</code> adds a <code>visualize</code> tool to your MCP server, rendering charts in Claude/ChatGPT from your query results. No frontend code.</td>\n<td>Charts the result, not a full semantic layer engine. Pair it with one.</td>\n</tr>\n</tbody>\n</table>\n<p>The right choice depends on your primary consumer. If internal dashboards are the only output and you’re already in Looker or Power BI, the embedded semantic layer may be sufficient. If you’re serving multiple consumers (customer-facing products, AI agents, APIs, internal dashboards), you need a standalone semantic layer that isn’t locked to one tool.</p>\n<h2 id=\"warehouse-specific-considerations\">Warehouse-specific considerations</h2>\n<p>Your semantic layer needs to work with your warehouse. The integration isn’t just “can it connect.” It’s how well the semantic layer exploits each warehouse’s strengths.</p>\n<p><strong><a href=\"/integrations/snowflake\">Snowflake</a>.</strong> Pre-aggregations work well with Snowflake’s auto-suspend compute. The semantic layer can target specific virtual warehouses for different workloads: fast cache rebuilds on a small warehouse, heavy ad-hoc queries on a larger one. Snowflake’s separation of storage and compute means pre-aggregation tables don’t compete with production workloads.</p>\n<p><strong><a href=\"/integrations/bigquery\">BigQuery</a>.</strong> BigQuery’s slot-based pricing means pre-aggregations save real money. Every query scans data and costs money. Caching a daily revenue rollup avoids scanning terabytes of raw data on every request. The semantic layer’s pre-aggregation layer can cut BigQuery costs by 10-100x for frequently queried metrics.</p>\n<p><strong><a href=\"/integrations/databricks\">Databricks</a>.</strong> Unity Catalog provides table-level governance. The semantic layer adds metric-level governance on top: defining what “revenue” means, not just who can access the <code>orders</code> table. Databricks’ Photon engine handles the heavy queries. The semantic layer caches the results.</p>\n<p><strong><a href=\"/integrations/postgres\">PostgreSQL</a> (including Supabase, Neon, and RDS).</strong> Works well for smaller datasets and development. Most teams start with Postgres for prototyping their semantic layer and migrate to a cloud warehouse as data volume grows. Pre-aggregation is especially valuable here because Postgres doesn’t have the raw compute power of cloud warehouses for large analytical queries.</p>\n<p><strong><a href=\"/integrations/duckdb\">DuckDB</a> (including MotherDuck).</strong> In-process analytical database. Useful for local development and testing without a remote warehouse connection. Define your metrics, test locally against DuckDB, deploy to production against Snowflake or BigQuery. Same schema, different warehouses.</p>\n<h2 id=\"how-to-evaluate-a-semantic-layer\">How to evaluate a semantic layer</h2>\n<p>If you’re choosing a semantic layer for your stack, here’s what to look at:</p>\n<p><strong>Who are your consumers?</strong> This is the most important question. If it’s only internal dashboards, an embedded semantic layer in your BI tool might be enough. If it’s dashboards plus a customer-facing product plus AI agents, you need a standalone semantic layer with API serving.</p>\n<p><strong>Does it support your warehouse?</strong> Check for native support, not just “we can connect via JDBC.” Native support means the semantic layer generates optimized SQL for your specific warehouse dialect and exploits warehouse-specific features like Snowflake’s clustering or BigQuery’s partitioning.</p>\n<p><strong>How does it handle caching?</strong> Pre-aggregation support varies widely. Some tools don’t cache at all (every query hits the warehouse). Some cache at the query level. The best cache at the metric level with configurable refresh schedules and automatic invalidation. If AI agents are querying your data, caching is not optional.</p>\n<p><strong>Multi-tenancy.</strong> If you’re building a B2B product, every query needs tenant isolation. Look for structural multi-tenancy (security context in the schema), not middleware-level filtering. One missed filter is a data leak.</p>\n<p><strong>Schema-as-code.</strong> Metric definitions should live in version control. Changes should go through pull requests. Rollbacks should be <code>git revert</code>, not clicking through a UI. If you can’t diff your metric definitions, you can’t govern them.</p>\n<p><strong>AI agent support.</strong> If AI agents are current or future consumers, look for <a href=\"/glossary/mcp\">MCP</a> support or a tool-use interface. An API is the minimum. MCP is the emerging standard for agent-to-tool communication and gives agents native discovery of available metrics.</p>\n<h2 id=\"when-do-you-need-a-semantic-layer\">When do you need a semantic layer?</h2>\n<p>Not every team needs one. Here’s when it starts to matter.</p>\n<p><strong>You need one when:</strong></p>\n<ul>\n<li>Multiple consumers query the same metrics (dashboards + API + agents)</li>\n<li>Different teams report different numbers for the same question</li>\n<li>You’re shipping analytics to customers (B2B <a href=\"/glossary/embedded-analytics\">embedded analytics</a>)</li>\n<li>AI agents need access to your data with governance</li>\n<li>New analysts spend days learning which queries are canonical</li>\n<li>Your data team is stuck answering ad-hoc metric questions instead of building</li>\n</ul>\n<p><strong>You probably don’t need one when:</strong></p>\n<ul>\n<li>One analyst queries the warehouse with SQL and shares results in spreadsheets</li>\n<li>You have a single dashboard tool and no API consumers</li>\n<li>Your data model is small enough that everyone knows the canonical queries</li>\n</ul>\n<p>The threshold is usually “more than one consumer for the same metrics.” Once dashboards, APIs, and applications all need revenue numbers, a shared definition pays for itself immediately.</p>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>If you’re evaluating options, start with what your primary consumer needs. Internal dashboards only? Your BI tool’s built-in semantic layer may be enough. dbt-native workflow with documentation as the goal? Start with MetricFlow. Multiple consumers including AI agents? A standalone semantic layer like <a href=\"https://cube.dev\">Cube</a> or <a href=\"https://www.atscale.com\">AtScale</a>, with metrics defined once and served over an API.</p>\n<p>When AI agents are a consumer, the last mile is the chart. An agent queries a metric, then needs to show the result in the conversation. Add that with one tool:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p><code>addCharts</code> registers a <code>visualize</code> tool on your MCP server. The agent calls it with a query, your <code>runSql</code> returns the rows, and Bonnard renders an interactive chart in Claude or ChatGPT. Bonnard never touches your database. Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>. Full walkthrough: <a href=\"/blog/mcp-charts\">MCP Charts</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-a-semantic-layer-in-simple-terms\">What is a semantic layer in simple terms?</h3>\n<p>A semantic layer is a single place where you define what business terms like “revenue” or “active user” mean in terms of database columns and calculations. Every tool that needs that number references the same definition instead of writing its own query. One definition, every consumer, same answer.</p>\n<h3 id=\"what-is-the-difference-between-a-semantic-layer-and-a-data-warehouse\">What is the difference between a semantic layer and a data warehouse?</h3>\n<p>A data warehouse stores your data. A semantic layer defines what the data means. The warehouse has a column called <code>amount</code>. The semantic layer defines that <code>total_revenue = SUM(amount) WHERE status != 'refunded'</code>. They work together: the warehouse holds the data, the semantic layer holds the business logic.</p>\n<h3 id=\"is-dbt-a-semantic-layer\">Is dbt a semantic layer?</h3>\n<p>dbt is primarily a transformation tool that models raw data into clean tables. dbt’s MetricFlow adds metric definition capabilities, which is one part of a semantic layer. But a full semantic layer also includes caching, multi-tenancy, access control, and API serving, which MetricFlow doesn’t provide on its own. Most teams use dbt for transformations and a separate semantic layer for metric serving.</p>\n<h3 id=\"what-is-the-difference-between-a-semantic-layer-and-an-obt-one-big-table\">What is the difference between a semantic layer and an OBT (One Big Table)?</h3>\n<p>An OBT flattens everything into a single denormalized table. A semantic layer keeps your data normalized and defines metrics as calculations across tables. OBTs are simpler at first but break down at scale: they’re expensive to rebuild, hard to govern, and create massive tables with redundant data. A semantic layer gives you the same query simplicity without the storage and maintenance cost.</p>\n<h3 id=\"what-is-the-difference-between-a-semantic-layer-and-a-metrics-layer\">What is the difference between a semantic layer and a metrics layer?</h3>\n<p>The terms are often used interchangeably, but a metrics layer is a subset of a semantic layer. A metrics layer defines measures and dimensions. A semantic layer adds relationships (joins), access control, caching, and API serving on top. dbt’s MetricFlow is a metrics layer. Cube and AtScale are full semantic layers.</p>\n<h3 id=\"do-i-need-a-semantic-layer-for-ai-agents\">Do I need a semantic layer for AI agents?</h3>\n<p>If AI agents query your data, a semantic layer is the difference between “plausible numbers” and “correct numbers.” Without one, agents interpret column names and generate ad-hoc SQL. With one, agents query governed metric definitions and get the same answer every time. See <a href=\"/blog/what-is-agentic-semantic-layer\">What Is an Agentic Semantic Layer?</a> for the full breakdown.</p>\n<h3 id=\"how-does-a-semantic-layer-handle-performance\">How does a semantic layer handle performance?</h3>\n<p>Through <a href=\"/glossary/pre-aggregation\">pre-aggregation</a>. The semantic layer pre-computes common rollups (daily revenue by status, weekly order count by category) and caches the results. Queries hit the cache instead of scanning raw tables. Hot queries resolve in single-digit milliseconds. The cache rebuilds on a configurable schedule with automatic invalidation when source data changes.</p>\n<h3 id=\"can-i-use-a-semantic-layer-with-snowflake-bigquery-and-other-warehouses\">Can I use a semantic layer with Snowflake, BigQuery, and other warehouses?</h3>\n<p>Yes. Modern semantic layers are warehouse-agnostic. You configure the connection to your warehouse (<a href=\"/integrations/snowflake\">Snowflake</a>, <a href=\"/integrations/bigquery\">BigQuery</a>, <a href=\"/integrations/databricks\">Databricks</a>, <a href=\"/integrations/postgres\">PostgreSQL</a> (including Supabase, Neon, and RDS), <a href=\"/integrations/redshift\">Redshift</a>, <a href=\"/integrations/duckdb\">DuckDB</a> (including MotherDuck)) and the semantic layer generates the appropriate SQL dialect. Swap warehouses without changing your metric definitions.</p>\n<h3 id=\"what-is-a-universal-semantic-layer\">What is a universal semantic layer?</h3>\n<p>A universal semantic layer serves every consumer from one set of definitions, regardless of what tool they use. Instead of defining metrics separately in Looker, Power BI, and your application code, you define them once and serve them everywhere. “Universal” means tool-agnostic: the semantic layer isn’t embedded in any single BI product.</p>\n",
      "date_published": "2026-03-19T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/what-is-agentic-semantic-layer",
      "url": "https://bonnard.dev/blog/what-is-agentic-semantic-layer",
      "title": "What Is an Agentic Semantic Layer?",
      "summary": "An agentic semantic layer is a metrics layer built for AI agents: business logic defined once, exposed via MCP or API, queried as governed definitions.",
      "content_html": "<p>An agentic semantic layer is a metadata layer between AI agents and a data warehouse that defines business metrics, enforces access control, and exposes governed query interfaces. Instead of writing raw SQL, agents query metric definitions through protocols like <a href=\"/glossary/mcp\">MCP</a> (Model Context Protocol) or REST APIs. Every agent gets the same answer because the business logic is defined once, not interpreted per query.</p>\n<p>The “agentic” distinction matters. Traditional <a href=\"/glossary/semantic-layer\">semantic layers</a> were built for BI dashboards and human analysts. An agentic semantic layer is built for programmatic consumers: LLMs, AI agents, SDKs, and applications. The interface, security model, and deployment patterns are different.</p>\n<h2 id=\"the-problem-with-ai-analytics-today\">The problem with AI analytics today</h2>\n<p>AI agents are becoming a primary interface to data. Executives ask Claude for quarterly numbers. Product managers ask Cursor for usage metrics. Customer success teams ask chatbots for account health scores. The agent is the new dashboard.</p>\n<p>But most data infrastructure wasn’t built for this. Two problems dominate.</p>\n<h3 id=\"text-to-sql-breaks-in-production\">Text-to-SQL breaks in production</h3>\n<p>The default approach: give an agent access to your warehouse and let it write SQL. It works in demos. In production, three failure modes show up every time.</p>\n<p><strong>Inconsistent answers.</strong> Ask Claude “What was Q1 revenue?” It writes a query summing <code>amount</code> from <code>orders</code> where <code>created_at</code> falls in Q1. Reasonable. Ask GPT the same question. It filters on <code>status = 'completed'</code> first, then sums. Different number. Also reasonable. Neither agent knows that your finance team excludes refunds and trial conversions. That logic lives in a Notion doc that got updated six months ago. The agents don’t have access to it. They generate plausible SQL from column names and return plausible numbers that don’t match anything your team reports.</p>\n<p>This isn’t a model quality problem. It’s a context problem. The agent sees schema, not semantics. Better models produce better-looking SQL that’s still wrong in the same ways.</p>\n<p><strong>No access control.</strong> You’re building a B2B product. Customer A asks your agent a question. The agent writes SQL against the warehouse. Nothing in the <a href=\"/glossary/text-to-sql\">text-to-SQL</a> pipeline enforces that Customer A only sees Customer A’s data. You can patch this: add tenant filters to prompts, write middleware that rewrites queries, build a validation layer. Each patch is a new surface for bugs. One missed filter is a data leak in production.</p>\n<p><strong>No audit trail.</strong> When your CFO asks “where did this number come from?”, the answer is “an LLM generated some SQL.” You can’t trace which metric definition was used because there wasn’t one. You can’t reproduce the result because the prompt context has changed. You can’t prove the number was correct because correctness wasn’t defined.</p>\n<h3 id=\"legacy-semantic-layers-werent-built-for-agents\">Legacy semantic layers weren’t built for agents</h3>\n<p>Traditional semantic layers solve the consistency problem for dashboards. Looker’s LookML, Tableau’s semantic model, Power BI’s DAX measures. They define metrics once and serve them to BI consumers. That’s genuinely useful.</p>\n<p>But they were designed for a world where a human browses a catalog, selects metrics, and views a dashboard. That interaction model doesn’t translate to AI agents.</p>\n<p>Agents don’t browse catalogs. They call tools at runtime. They need programmatic discovery (what metrics exist?), programmatic querying (give me revenue by region for Q1), and programmatic access control (this agent represents Tenant X and can only see Tenant X’s data). They need this over a standardized protocol, not a proprietary query language embedded in a BI tool.</p>\n<p>A BI-embedded semantic layer can’t serve an MCP client. It can’t issue publishable keys per tenant. It can’t serve a React SDK, a TypeScript SDK, and a markdown dashboard from the same definitions. The interface is wrong.</p>\n<p>That’s what an agentic semantic layer changes.</p>\n<h2 id=\"how-it-differs-from-a-traditional-semantic-layer\">How it differs from a traditional semantic layer</h2>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>Traditional semantic layer</th>\n<th>Agentic semantic layer</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Primary consumer</strong></td>\n<td>BI dashboards, analysts</td>\n<td>AI agents, LLMs, applications</td>\n</tr>\n<tr>\n<td><strong>Interface</strong></td>\n<td>SQL, proprietary query language (LookML, DAX)</td>\n<td>MCP, REST API, SDK</td>\n</tr>\n<tr>\n<td><strong>Discovery</strong></td>\n<td>Human browses a catalog in a GUI</td>\n<td>Agent calls <code>explore_schema</code> at runtime</td>\n</tr>\n<tr>\n<td><strong>Multi-tenancy</strong></td>\n<td>Often manual or absent</td>\n<td>Per-query enforcement, publishable keys per tenant</td>\n</tr>\n<tr>\n<td><strong>Access control</strong></td>\n<td>Dashboard-level or role-based</td>\n<td>Row-level, per-consumer, structural</td>\n</tr>\n<tr>\n<td><strong>Query volume</strong></td>\n<td>Dozens of dashboard refreshes per hour</td>\n<td>Hundreds of agent queries per minute</td>\n</tr>\n<tr>\n<td><strong>Caching</strong></td>\n<td>Dashboard-level refresh</td>\n<td><a href=\"/glossary/pre-aggregation\">Pre-aggregation</a> with automatic invalidation</td>\n</tr>\n<tr>\n<td><strong>Deployment</strong></td>\n<td>GUI-configured, click-ops</td>\n<td>YAML in Git, CLI-driven, CI/CD</td>\n</tr>\n<tr>\n<td><strong>Schema management</strong></td>\n<td>GUI editor or proprietary file format</td>\n<td>Version-controlled code, PR reviews</td>\n</tr>\n</tbody>\n</table>\n<p>The core shift: an agentic semantic layer treats programmatic access as the primary use case, not an afterthought. MCP support, multi-tenant keys, and row-level security aren’t add-ons. They’re the foundation.</p>\n<h2 id=\"core-capabilities\">Core capabilities</h2>\n<p>An agentic semantic layer needs five capabilities to work in production. Missing any one of them and you’ll end up rebuilding it later.</p>\n<h3 id=\"1-machine-readable-metric-definitions\">1. Machine-readable metric definitions</h3>\n<p>Metrics defined in YAML or a similar declarative format that both humans and machines can read. Not trapped in a GUI. Not embedded in a proprietary tool.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' AND type != 'trial' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        description</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"Total revenue excluding refunds and trials\"</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span></code></pre>\n<p><code>total_revenue</code> is now a governed definition with a description the agent can read. Deploy this schema and any MCP-compatible agent (Claude, Cursor, or any client supporting the protocol) can discover and query these metrics at runtime.</p>\n<p>The <code>description</code> field matters more than it looks. When an agent calls <code>explore_schema</code>, descriptions are what it uses to decide which metric answers the user’s question. Good descriptions are the difference between an agent that picks the right metric and one that guesses.</p>\n<h3 id=\"2-programmatic-discovery-and-querying\">2. Programmatic discovery and querying</h3>\n<p>Agents need a standardized way to find out what metrics exist and query them. This is what <a href=\"/glossary/mcp\">MCP</a> (Model Context Protocol) provides.</p>\n<p>The agent calls <code>explore_schema</code> to see available cubes, measures, and dimensions. It reads descriptions to understand what each metric represents. Then it calls <code>query</code> with the right measures, dimensions, and filters. It never writes SQL. The semantic layer handles query generation, execution, caching, and access control.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>Agent: explore_schema → \"orders cube has total_revenue, order_count, status, category, created_at\"</span></span>\n<span class=\"line\"><span>Agent: query(measures: [total_revenue], dimensions: [status], filters: [{created_at: last 90 days}])</span></span>\n<span class=\"line\"><span>Semantic layer: generates SQL, executes against warehouse, returns structured result</span></span></code></pre>\n<p>This is fundamentally different from text-to-SQL. The agent isn’t interpreting column names. It’s selecting from governed definitions. The query isn’t generated by the LLM. It’s generated by the semantic layer’s query engine, which knows how to write correct SQL for your specific warehouse dialect.</p>\n<p>Five tools make this work:</p>\n<ul>\n<li><strong><code>explore_schema</code></strong>: Discover available cubes, measures, and dimensions with descriptions</li>\n<li><strong><code>query</code></strong>: Fetch aggregated data using governed metric definitions</li>\n<li><strong><code>sql_query</code></strong>: Run queries for edge cases that need custom SQL (still governed by access controls)</li>\n<li><strong><code>describe_field</code></strong>: Get detailed metadata about a specific measure or dimension</li>\n<li><strong><code>visualize</code></strong>: Render charts directly in the conversation from query results</li>\n</ul>\n<h3 id=\"3-structural-multi-tenancy\">3. Structural multi-tenancy</h3>\n<p>If you’re building a B2B product, every agent query needs to be scoped to a specific tenant. This can’t be a prompt-level instruction (“only show data for customer X”). It needs to be structural: impossible to bypass regardless of what the agent does.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">    security_context</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">tenant_filter</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"{SECURITY_CONTEXT.tenant_id} = customer_id\"</span></span></code></pre>\n<p>Every query for a given tenant automatically includes this filter. The consumer can’t skip it. The agent can’t override it. Generate a publishable key per tenant, and that key carries the security context. The agent authenticates with the key and every query is automatically scoped.</p>\n<p>This is how you ship AI-powered analytics to B2B customers without building a custom access control layer. The semantic layer handles isolation. You handle the product.</p>\n<h3 id=\"4-pre-aggregation-for-agent-scale-query-volume\">4. Pre-aggregation for agent-scale query volume</h3>\n<p>AI agents make more queries than humans. A human refreshes a dashboard once. An agent might make 10 queries to answer one question: exploring the schema, trying different dimensions, following up on anomalies.</p>\n<p>Pre-aggregation handles this. Define which metric combinations to pre-compute:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">    pre_aggregations</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">daily_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">category</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        time_dimension</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        granularity</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">day</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        refresh_key</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">          every</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">1 hour</span></span></code></pre>\n<p>The semantic layer builds and maintains these rollups. Hot queries hit the cache (single-digit milliseconds). Cold queries fall through to the warehouse. Without this, agent workloads can overwhelm your warehouse, especially when multiple customers’ agents are querying simultaneously.</p>\n<p><a href=\"/glossary/pre-aggregation\">Pre-aggregation</a> isn’t a performance optimization. At agent scale, it’s a requirement.</p>\n<h3 id=\"5-schema-as-code-with-version-control\">5. Schema-as-code with version control</h3>\n<p>Metric definitions should live in Git. Changes should go through pull requests. Rollbacks should be <code>git revert</code>.</p>\n<p>This isn’t just good engineering practice. It’s how you maintain trust in an agentic system. When someone asks “why did the revenue number change?”, the answer is a Git commit, not “someone clicked something in the admin UI.” When a definition is wrong, you revert it and every consumer immediately gets the corrected version. No agent retrained. No dashboard patched. One diff in your schema repo.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">git</span><span style=\"color:#032F62\"> diff</span><span style=\"color:#032F62\"> HEAD~1</span><span style=\"color:#032F62\"> schema/orders.yml</span><span style=\"color:#6A737D\">  # see what changed</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">git</span><span style=\"color:#032F62\"> revert</span><span style=\"color:#032F62\"> abc123</span><span style=\"color:#6A737D\">                   # undo a bad metric definition</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># redeploy your semantic layer, and every consumer gets the fix</span></span></code></pre>\n<h2 id=\"who-needs-an-agentic-semantic-layer\">Who needs an agentic semantic layer?</h2>\n<p>Different teams interact with the agentic semantic layer differently. The common thread: everyone benefits from one source of truth for metric definitions.</p>\n<p><strong>Data engineers and analytics engineers</strong> define the metrics. They write the YAML, review changes in PRs, and manage pre-aggregation strategies. The agentic semantic layer gives them a single place to define business logic instead of maintaining it across dashboards, notebooks, and custom API endpoints.</p>\n<p><strong>Engineering leads and product teams</strong> consume the metrics. They connect AI agents via MCP, build features on the REST API, and let agents chart query results in the conversation. The semantic layer means they don’t need the data team to build a custom endpoint for every new feature.</p>\n<p><strong>Data leaders</strong> govern the metrics. They ensure definitions are correct, access controls are appropriate, and audit trails are maintained. The semantic layer centralizes governance instead of distributing it across tools.</p>\n<p><strong>Customers (in B2B products)</strong> query their own data. Through embedded dashboards in your product, through AI agents connected via publishable keys, or through APIs. The semantic layer ensures they see only their data, with the same metric definitions your internal teams use.</p>\n<h2 id=\"how-it-fits-the-modern-data-stack\">How it fits the modern data stack</h2>\n<p>An agentic semantic layer doesn’t replace your existing infrastructure. It sits on top of it.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>[Data Sources] → [Ingestion (Fivetran, Airbyte)] → [Warehouse (Snowflake, BigQuery)]</span></span>\n<span class=\"line\"><span>                                                            ↓</span></span>\n<span class=\"line\"><span>                                                    [dbt transformations]</span></span>\n<span class=\"line\"><span>                                                            ↓</span></span>\n<span class=\"line\"><span>                                                  [Agentic Semantic Layer]</span></span>\n<span class=\"line\"><span>                                                    ↙    ↓    ↘</span></span>\n<span class=\"line\"><span>                                            [MCP Agents] [React SDK] [REST API]</span></span>\n<span class=\"line\"><span>                                            [Dashboards] [TypeScript SDK]</span></span></code></pre>\n<p>Your ingestion pipeline feeds raw data into the warehouse. dbt transforms it into clean tables. The agentic semantic layer defines business metrics on those tables and serves them to every consumer through the appropriate interface.</p>\n<p>The semantic layer connects to your warehouse (<a href=\"/integrations/snowflake\">Snowflake</a>, <a href=\"/integrations/bigquery\">BigQuery</a>, <a href=\"/integrations/databricks\">Databricks</a>, <a href=\"/integrations/postgres\">PostgreSQL</a> (including Supabase, Neon, and RDS), <a href=\"/integrations/redshift\">Redshift</a>, <a href=\"/integrations/duckdb\">DuckDB</a> (including MotherDuck)) and generates the appropriate SQL dialect. Swap warehouses without changing your metric definitions or consumer integrations.</p>\n<p>This is the key architectural property: the semantic layer decouples metric definitions from both the warehouse and the consumer. Change your warehouse and consumers don’t notice. Add a new consumer and the warehouse doesn’t change. The semantic layer is the stable interface between infrastructure and applications.</p>\n<h2 id=\"agentic-semantic-layer-tools-compared\">Agentic semantic layer tools compared</h2>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Agent support</th>\n<th>Multi-tenancy</th>\n<th>Pre-aggregation</th>\n<th>Open source</th>\n<th>Primary use case</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong><a href=\"https://cube.dev\">Cube</a></strong></td>\n<td>REST API (no MCP)</td>\n<td>Manual</td>\n<td>Yes (battle-tested)</td>\n<td>Yes</td>\n<td>Headless BI, API-first analytics</td>\n</tr>\n<tr>\n<td><strong>dbt MetricFlow</strong></td>\n<td>None</td>\n<td>None</td>\n<td>None</td>\n<td>Yes</td>\n<td>Metric documentation in dbt</td>\n</tr>\n<tr>\n<td><strong>Looker (LookML)</strong></td>\n<td>None</td>\n<td>Manual</td>\n<td>Limited</td>\n<td>No</td>\n<td>Enterprise dashboard BI</td>\n</tr>\n<tr>\n<td><strong>AtScale</strong></td>\n<td>Partial</td>\n<td>Enterprise</td>\n<td>Virtual caching</td>\n<td>No</td>\n<td>Enterprise BI compatibility</td>\n</tr>\n<tr>\n<td><strong>ThoughtSpot</strong></td>\n<td>Proprietary (“Spotter”)</td>\n<td>Enterprise</td>\n<td>Proprietary</td>\n<td>No</td>\n<td>AI-powered BI platform</td>\n</tr>\n<tr>\n<td><strong><a href=\"https://bonnard.dev\">Bonnard</a></strong></td>\n<td>MCP <code>visualize</code> tool</td>\n<td>Via your <code>runSql</code></td>\n<td>Via your warehouse</td>\n<td>Yes</td>\n<td>Charts agent query results in Claude/ChatGPT</td>\n</tr>\n</tbody>\n</table>\n<p>The right choice depends on whether AI agents are your primary consumer or a secondary integration. If agents are an afterthought, most semantic layers can be retrofitted with API access. If agents are the primary interface, you need a layer designed for programmatic consumers from the ground up.</p>\n<h2 id=\"getting-started\">Getting started</h2>\n<p>An agentic semantic layer gives the agent governed metrics to query. The last mile is what the agent shows the user: not a wall of numbers, a chart in the conversation. That’s what <a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>@bonnard/mcp-charts</code></a> adds, a <code>visualize</code> tool you register on your MCP server in a few lines.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// your data, your connection. Bonnard never touches the database</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>The agent calls <code>visualize</code> with a query, your <code>runSql</code> returns the rows, and Bonnard infers the chart from the typed result, then renders an interactive widget in Claude or ChatGPT. Native adapters ship for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or pass your own <code>runSql</code>. It renders line, bar, area, pie, scatter, funnel, waterfall, and table.</p>\n<p>For the full walkthrough, see <a href=\"/blog/mcp-charts\">MCP Charts</a>. For the design behind an agent-friendly chart tool, see <a href=\"/blog/how-bonnard-builds-agent-friendly-mcps\">How Bonnard Builds Agent-Friendly MCPs</a>. For a broader look at semantic layers beyond the agentic use case, see <a href=\"/blog/what-is-a-semantic-layer\">What Is a Semantic Layer?</a>.</p>\n<p>Repo: <a href=\"https://github.com/bonnard-data/mcp-charts\">github.com/bonnard-data/mcp-charts</a>.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"what-is-a-semantic-layer-in-simple-terms\">What is a semantic layer in simple terms?</h3>\n<p>A semantic layer is a translation layer between raw data and the people or tools that query it. It defines what business terms like “revenue” or “active user” mean in terms of actual database columns and calculations. Instead of every consumer writing its own SQL, they all reference the same definition. See <a href=\"/blog/what-is-a-semantic-layer\">What Is a Semantic Layer?</a> for the full guide.</p>\n<h3 id=\"what-makes-a-semantic-layer-agentic\">What makes a semantic layer “agentic”?</h3>\n<p>Three things: programmatic discovery (agents find metrics via API, not a GUI catalog), programmatic querying (agents call tools, not SQL), and structural multi-tenancy (access control enforced per query, not per dashboard). A traditional semantic layer might have an API, but an agentic one is designed for agents as the primary consumer.</p>\n<h3 id=\"how-is-an-agentic-semantic-layer-different-from-rag\">How is an agentic semantic layer different from RAG?</h3>\n<p>RAG (Retrieval-Augmented Generation) feeds unstructured documents to an LLM for context. An agentic semantic layer provides structured metric definitions for data queries. RAG answers “What does our refund policy say?” A semantic layer answers “What was Q1 revenue?” They solve different problems and are often used together: RAG for knowledge, semantic layer for data.</p>\n<h3 id=\"how-is-an-agentic-semantic-layer-different-from-text-to-sql\">How is an agentic semantic layer different from text-to-SQL?</h3>\n<p>Text-to-SQL lets an agent generate arbitrary SQL from natural language. The agent interprets column names and guesses business logic. An agentic semantic layer defines metrics once and lets agents query those definitions. The difference: text-to-SQL produces plausible answers. A semantic layer produces correct, governed, auditable answers. See <a href=\"/glossary/text-to-sql\">text-to-SQL</a> for more on why the raw approach breaks in production.</p>\n<h3 id=\"do-i-need-an-agentic-semantic-layer-if-i-use-dbt\">Do I need an agentic semantic layer if I use dbt?</h3>\n<p>dbt defines transformations: how raw data becomes clean tables. An agentic semantic layer defines metrics: what “revenue” means on top of those tables, and serves them to agents with caching and access control. They’re complementary. dbt gets data into shape. The semantic layer defines the business logic on top and serves it. Most agentic semantic layers can import dbt models directly.</p>\n<h3 id=\"what-is-mcp-and-why-does-it-matter\">What is MCP and why does it matter?</h3>\n<p><a href=\"/glossary/mcp\">MCP</a> (Model Context Protocol) is a standard protocol for connecting AI agents to external tools and data sources. It defines how agents discover available tools, call them, and receive results. For an agentic semantic layer, MCP is the interface that lets any compatible agent (Claude, Cursor, Claude Code, and others) discover and query your metrics without custom integration code.</p>\n<h3 id=\"can-any-ai-model-use-an-agentic-semantic-layer\">Can any AI model use an agentic semantic layer?</h3>\n<p>Yes. A semantic layer with MCP support works with any MCP-compatible client. For other agents, REST APIs and SDKs provide model-agnostic access. The semantic layer sits between the agent and the warehouse, not inside the model. It doesn’t matter whether the agent runs Claude, GPT, Gemini, or an open-source model.</p>\n<h3 id=\"what-is-the-performance-impact\">What is the performance impact?</h3>\n<p>With <a href=\"/glossary/pre-aggregation\">pre-aggregation</a>, queries get faster, not slower. The semantic layer caches rollups so agents query pre-computed results instead of running full aggregations on every request. Hot queries resolve in single-digit milliseconds. Without pre-aggregation, agent-scale query volumes (hundreds of queries per minute across multiple tenants) will overwhelm most warehouses.</p>\n<h3 id=\"how-does-an-agentic-semantic-layer-handle-hallucinations\">How does an agentic semantic layer handle hallucinations?</h3>\n<p>It eliminates the primary source of data hallucinations: ad-hoc SQL generation. The agent never writes SQL. It selects from governed metric definitions and the semantic layer generates correct SQL. The agent can still hallucinate its interpretation of the results (that’s a model problem), but the underlying data is always correct and traceable to a versioned definition.</p>\n",
      "date_published": "2026-03-16T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/why-ai-agents-need-semantic-layer",
      "url": "https://bonnard.dev/blog/why-ai-agents-need-semantic-layer",
      "title": "Why Your AI Agents Need a Semantic Layer",
      "summary": "Why AI agents need a semantic layer: raw SQL produces inconsistent, ungoverned results. What goes wrong without one, and how governed metrics change it.",
      "content_html": "<p>Give an AI agent access to your data warehouse and it will write SQL. It might even write good SQL. But ask two agents the same revenue question and you’ll get two different numbers. Neither matches finance’s report. That’s the core problem: text-to-SQL gives agents access to data, not understanding of it.</p>\n<p>A <a href=\"/glossary/semantic-layer\">semantic layer</a> for AI agents fixes this by putting a governed metrics layer between the agent and the warehouse. The agent queries defined metrics instead of raw tables. Every consumer, whether it’s an AI agent, a dashboard, or an API, gets the same answer because the calculation is fixed, not interpreted per query.</p>\n<p>This isn’t a new idea. Semantic layers have existed in BI for decades. What’s new is that AI agents make the problem worse and the solution more urgent.</p>\n<h2 id=\"what-actually-goes-wrong-without-a-semantic-layer\">What actually goes wrong without a semantic layer?</h2>\n<p>Three failure modes show up in production. All of them trace back to the same root cause: the agent interprets business logic on every query instead of referencing a shared definition.</p>\n<h3 id=\"inconsistent-answers\">Inconsistent answers</h3>\n<p>Ask Claude “What was Q1 revenue?” through <a href=\"/glossary/text-to-sql\">text-to-SQL</a>. It writes a query summing <code>amount</code> from <code>orders</code> where <code>created_at</code> is between January 1 and March 31. Reasonable.</p>\n<p>Ask GPT the same question. It filters on <code>status = 'completed'</code> first, then sums. Different number. Also reasonable.</p>\n<p>Neither agent knows that your finance team excludes refunds and trial conversions from revenue. That logic lives in a Notion doc that got updated six months ago. The agents don’t have access to it. They generate plausible SQL from column names and return plausible numbers that don’t match anything your team reports.</p>\n<p>This isn’t a model quality problem. It’s a context problem. The agent sees schema, not semantics.</p>\n<h3 id=\"no-access-control\">No access control</h3>\n<p>You’re building a B2B product. Customer A asks your agent a question. The agent writes SQL against the warehouse. Nothing in the text-to-SQL pipeline enforces that Customer A only sees Customer A’s data.</p>\n<p>You can patch this. Add tenant filters to prompts. Write middleware that rewrites queries. Build a validation layer. Each patch is a new surface for bugs. One missed filter and you’ve got a data leak in production.</p>\n<p><a href=\"/glossary/rbac\">RBAC</a> needs to be structural, not bolted on. It should be impossible for an agent to return data outside its authorized scope, regardless of what SQL it generates.</p>\n<h3 id=\"no-audit-trail\">No audit trail</h3>\n<p>When your CFO asks “where did this number come from?”, the answer is “an LLM generated some SQL.” That’s not an audit trail. You can’t trace which metric definition was used because there wasn’t one. You can’t reproduce the result because the prompt context has changed. You can’t prove the number was correct because correctness wasn’t defined.</p>\n<p><a href=\"/glossary/metric-governance\">Metric governance</a> requires a fixed, versioned definition that every consumer references. Without it, every query is an ad hoc interpretation of raw data.</p>\n<h2 id=\"what-is-a-semantic-layer-for-ai-agents\">What is a semantic layer for AI agents?</h2>\n<p>A semantic layer is a metadata layer between your data warehouse and every data consumer. It defines business metrics, relationships between tables, and access rules in one place. Consumers query metric definitions instead of raw tables.</p>\n<p>For AI agents specifically, the semantic layer:</p>\n<ul>\n<li><strong>Translates natural language to governed queries.</strong> The agent asks for “revenue.” The semantic layer knows that means <code>SUM(amount) WHERE status != 'refunded' AND type != 'trial'</code>. The agent never writes this SQL itself.</li>\n<li><strong>Enforces multi-tenancy and access control.</strong> Every query runs through row-level security rules defined in the schema. The agent physically cannot return unauthorized data.</li>\n<li><strong>Provides an API, not a database connection.</strong> The agent calls a query endpoint or <a href=\"/glossary/mcp\">MCP</a> tool, not a database driver. The semantic layer generates the SQL, executes it, and returns structured results.</li>\n</ul>\n<p>The term “agentic semantic layer” describes a semantic layer built for this use case: agent-native interfaces (MCP, tool-use APIs), multi-tenant by default, designed for programmatic access rather than human-driven BI.</p>\n<h2 id=\"how-it-works-metrics-as-code\">How it works: metrics as code</h2>\n<p>Define your metrics in YAML. Version them in Git. Review changes in pull requests. This is the same workflow your engineering team uses for application code, applied to data definitions.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"CASE WHEN status != 'refunded' AND type != 'trial' THEN amount ELSE 0 END\"</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">order_count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">customer_id</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">customer_id</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span></code></pre>\n<p><code>total_revenue</code> isn’t a column. It’s a calculation with your business rules baked in. When finance decides to exclude a new edge case, one diff updates the definition for every consumer. No agent retrained. No dashboard patched. No Slack thread asking “which number is right.”</p>\n<p>Expose this to AI agents over MCP and the agent discovers available metrics at runtime. It calls a discovery tool to see what metrics exist, then a query tool to fetch data. It never generates SQL. It never interprets column names. It queries governed definitions.</p>\n<p>The next step is what the agent shows the user. A governed query returns rows; the user usually wants a chart. Leaving the model to draw HTML puts the most important part of the answer in its hands to improvise. A dedicated tool fixes that. <a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>@bonnard/mcp-charts</code></a> adds a <code>visualize</code> tool to your MCP server in a few lines:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// your data, your connection. Bonnard never touches the database</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>The agent calls <code>visualize</code> with a query, your <code>runSql</code> returns the rows, and Bonnard infers the chart from the typed result, then renders an interactive widget in Claude or ChatGPT. The chart comes from your query result, not from tokens the model invents. Same data, same chart, every time.</p>\n<p>For the full setup walkthrough, see <a href=\"/blog/connect-ai-agent-data-warehouse\">How to Connect an AI Agent to Your Data Warehouse</a> and the <a href=\"/blog/mcp-charts\">MCP Charts guide</a>.</p>\n<h2 id=\"agentic-semantic-layer-vs-traditional-bi-semantic-layer\">Agentic semantic layer vs traditional BI semantic layer</h2>\n<p>Not every semantic layer works well with AI agents. Most were built for BI tools and retrofitted. The difference matters in production.</p>\n<table>\n<thead>\n<tr>\n<th>Capability</th>\n<th>Traditional BI semantic layer</th>\n<th>Agentic semantic layer</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Primary consumer</td>\n<td>Dashboards, analysts</td>\n<td>AI agents, LLMs, applications</td>\n</tr>\n<tr>\n<td>Interface</td>\n<td>SQL or proprietary query language</td>\n<td>MCP, REST API, SDK</td>\n</tr>\n<tr>\n<td>Multi-tenancy</td>\n<td>Afterthought or manual</td>\n<td>Built-in, per-query enforcement</td>\n</tr>\n<tr>\n<td>Access control</td>\n<td>Dashboard-level</td>\n<td>Row-level, per-consumer</td>\n</tr>\n<tr>\n<td>Discovery</td>\n<td>Human browses catalog</td>\n<td>Agent calls <code>explore_schema</code> at runtime</td>\n</tr>\n<tr>\n<td>Caching</td>\n<td>Cube-level</td>\n<td><a href=\"/glossary/pre-aggregation\">Pre-aggregation</a> with automatic invalidation</td>\n</tr>\n<tr>\n<td>Deployment</td>\n<td>UI-driven</td>\n<td>CLI, Git, CI/CD</td>\n</tr>\n<tr>\n<td>Schema management</td>\n<td>GUI editor</td>\n<td>YAML in version control</td>\n</tr>\n</tbody>\n</table>\n<p>The core difference: an agentic semantic layer treats programmatic access as the default, not a secondary integration. MCP support, multi-tenant keys, and row-level security aren’t add-ons. They’re the foundation.</p>\n<h2 id=\"what-to-look-for-when-evaluating\">What to look for when evaluating</h2>\n<p>If you’re choosing a semantic layer for AI agent use cases, here’s what matters:</p>\n<p><strong>MCP or tool-use support.</strong> Your agents need a standardized way to discover metrics and query them. <a href=\"/glossary/mcp\">MCP</a> (Model Context Protocol) is the emerging standard. Without it, you’re writing custom integration code for every agent.</p>\n<p><strong>Multi-tenancy.</strong> If you’re building a B2B product, every agent query needs to be scoped to a specific tenant. This should be structural, not a prompt injection.</p>\n<p><strong>Row-level security.</strong> Beyond tenant scoping, you need fine-grained access control. Marketing agents see marketing data. Finance agents see finance data. Defined in the schema, enforced on every query.</p>\n<p><strong>Pre-aggregation.</strong> AI agents make more queries than humans. Sub-second response times require cached rollups, not full table scans on every request. Look for configurable <a href=\"/glossary/pre-aggregation\">pre-aggregation</a> with automatic cache invalidation.</p>\n<p><strong>Warehouse coverage.</strong> Your semantic layer needs to support your warehouse. At minimum: Snowflake, BigQuery, Databricks, PostgreSQL (including Supabase, Neon, and RDS). Bonus: DuckDB (including MotherDuck), Redshift.</p>\n<p><strong>Schema-as-code.</strong> Metric definitions should live in version control. Changes should go through pull requests. Rollbacks should be <code>git revert</code>, not clicking through a UI.</p>\n<p>Several tools in this space: <a href=\"https://cube.dev\">Cube</a> pioneered the open-source semantic layer. <a href=\"https://www.atscale.com\">AtScale</a> and <a href=\"https://www.getdbt.com\">dbt</a> offer semantic layer capabilities for different stacks. The right choice depends on your stack, your use case, and whether you need the semantic layer to serve AI agents as its primary consumer or as a secondary integration.</p>\n<p>Once the agent can query governed metrics, give it a way to chart the result. <a href=\"https://github.com/bonnard-data/mcp-charts\"><code>@bonnard/mcp-charts</code></a> adds a <code>visualize</code> tool to your MCP server that renders interactive charts in any MCP Apps client from your query results, with no frontend code.</p>\n<h2 id=\"where-this-is-heading\">Where this is heading</h2>\n<p>The a16z team <a href=\"https://a16z.com/your-data-agents-need-context/\">wrote recently</a> that data agents are “essentially useless without the right context.” They’re right. But context isn’t a feature you add later. It’s the layer you build on.</p>\n<p>The companies shipping <a href=\"/agentic-analytics\">agentic analytics</a> today are the ones that defined their metrics before connecting their agents. The ones struggling are the ones that gave agents raw warehouse access and are now debugging why different tools return different numbers.</p>\n<p>A semantic layer isn’t optional infrastructure for AI agents. It’s the control plane that makes everything downstream trustworthy.</p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"do-i-need-a-semantic-layer-if-i-already-use-dbt\">Do I need a semantic layer if I already use dbt?</h3>\n<p>dbt defines transformations: how raw data becomes clean tables. A semantic layer defines metrics: how clean tables become business numbers. They’re complementary. dbt gets your data into the right shape. The semantic layer defines what “revenue” means on top of that shape. Many semantic layers can import dbt models directly.</p>\n<h3 id=\"whats-the-difference-between-a-semantic-layer-and-rag\">What’s the difference between a semantic layer and RAG?</h3>\n<p>RAG (Retrieval-Augmented Generation) feeds documents to an LLM for context. A semantic layer feeds governed metric definitions to an agent for data queries. RAG is for unstructured knowledge (“What does our refund policy say?”). A semantic layer is for structured data (“What was Q1 revenue?”). You likely need both, but they solve different problems.</p>\n<h3 id=\"can-i-use-a-semantic-layer-with-claude-gpt-and-open-source-models\">Can I use a semantic layer with Claude, GPT, and open-source models?</h3>\n<p>Yes. A semantic layer with MCP support works with any MCP-compatible client: Claude Desktop, Cursor, Claude Code, and others. For non-MCP agents, most semantic layers expose REST APIs or SDKs. The semantic layer is model-agnostic because it sits between the agent and the warehouse, not inside the model.</p>\n<h3 id=\"how-is-this-different-from-giving-agents-read-only-database-access\">How is this different from giving agents read-only database access?</h3>\n<p>Read-only access prevents writes but doesn’t prevent incorrect reads. The agent still interprets column names, guesses JOIN conditions, and invents filter logic. A semantic layer replaces interpretation with definition. The agent queries <code>total_revenue</code> and gets the exact calculation your finance team agreed on, every time.</p>\n<h3 id=\"whats-the-performance-impact-of-adding-a-semantic-layer\">What’s the performance impact of adding a semantic layer?</h3>\n<p>With pre-aggregation, queries typically get faster, not slower. The semantic layer caches rollups so agents query pre-computed results instead of running full aggregations on every request. Cold queries hit the warehouse directly. Hot queries resolve in single-digit milliseconds.</p>\n",
      "date_published": "2026-03-16T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    },
    {
      "id": "https://bonnard.dev/blog/connect-ai-agent-data-warehouse",
      "url": "https://bonnard.dev/blog/connect-ai-agent-data-warehouse",
      "title": "How to Connect an AI Agent to Your Data Warehouse",
      "summary": "Connect an AI agent to your data warehouse: expose governed metrics over MCP, query without raw SQL, and chart the result in Claude or ChatGPT.",
      "content_html": "<p>Most teams connecting AI agents to their data warehouse start with text-to-SQL. The agent generates SQL from natural language, runs it against the warehouse, and returns results. It works until it doesn’t: hallucinated JOINs, inconsistent aggregations, no access control, no audit trail.</p>\n<p>There’s a better approach. Define your business metrics in a semantic layer, expose them via <a href=\"/glossary/mcp\">MCP</a> (Model Context Protocol), and let any AI agent query governed definitions instead of raw tables. Then add one tool so the agent can chart the result in Claude or ChatGPT. This tutorial shows how to set it up in under 30 minutes.</p>\n<h2 id=\"why-does-text-to-sql-break-in-production\">Why does text-to-SQL break in production?</h2>\n<p>The agent sees column names but not business logic. It doesn’t know that your company excludes refunds from revenue. It doesn’t know that <code>status = 'completed'</code> means something different in <code>orders</code> than in <code>subscriptions</code>. It doesn’t know that marketing and finance defined “active user” differently three years ago and never reconciled.</p>\n<p>So the agent writes plausible SQL and returns plausible numbers. Ask the same question twice with different phrasing and you get different answers. Ask two different agents and you get two different numbers. Neither matches the number your finance team reports.</p>\n<p>Beyond consistency, there’s no row-level security. No multi-tenancy. No audit trail showing which agent queried what, when, and for whom. In production, with real customers, that’s a non-starter.</p>\n<p><a href=\"/glossary/text-to-sql\">Text-to-SQL</a> gives you speed. It doesn’t give you trust.</p>\n<h2 id=\"what-is-the-semantic-layer-approach\">What is the semantic layer approach?</h2>\n<p>Instead of letting agents write arbitrary SQL, define your metrics once in YAML: cubes, measures, dimensions, access rules. Then expose those definitions via MCP so agents query governed metrics, not raw tables.</p>\n<p>The difference: every agent gets the same answer because the metric definition is fixed. <code>total_revenue</code> isn’t a column the agent interprets. It’s a pre-defined calculation with agreed-upon filters and aggregations. When your finance team updates the revenue definition to exclude trial conversions, that change propagates to every consumer instantly. No agent retrained. No dashboard patched. One diff in your schema repo.</p>\n<p>This architecture also decouples the query interface from the warehouse dialect. Swap BigQuery for Snowflake and your agents don’t notice. The semantic layer abstracts the SQL generation, so consumers stay stable while infrastructure evolves underneath.</p>\n<table>\n<thead>\n<tr>\n<th>Approach</th>\n<th>Governance</th>\n<th>Consistency</th>\n<th>Multi-tenant</th>\n<th>Access Control</th>\n<th>Setup Time</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Direct SQL</td>\n<td>None</td>\n<td>Varies by query</td>\n<td>Manual</td>\n<td>Manual</td>\n<td>Minutes</td>\n</tr>\n<tr>\n<td>Text-to-SQL</td>\n<td>None</td>\n<td>Varies by prompt</td>\n<td>Manual</td>\n<td>Manual</td>\n<td>Hours</td>\n</tr>\n<tr>\n<td>Semantic Layer via MCP</td>\n<td>Full</td>\n<td>Guaranteed</td>\n<td>Built-in</td>\n<td>Row-level</td>\n<td>Under 30 min</td>\n</tr>\n</tbody>\n</table>\n<p>The <a href=\"/semantic-layer\">semantic layer</a> is the control plane between your warehouse and every consumer, whether that’s a human analyst, a React component, or an AI agent.</p>\n<h2 id=\"step-1-set-up-your-semantic-layer\">Step 1: Set up your semantic layer</h2>\n<p>Stand up a semantic layer that connects to your warehouse and exposes metrics over MCP. Open-source engines like <a href=\"https://cube.dev\">Cube</a> connect to <a href=\"/integrations/bigquery\">BigQuery</a>, <a href=\"/integrations/snowflake\">Snowflake</a>, <a href=\"/integrations/redshift\">Redshift</a>, <a href=\"/integrations/databricks\">Databricks</a>, <a href=\"/integrations/postgres\">PostgreSQL</a> (including Supabase, Neon, and RDS), and <a href=\"/integrations/duckdb\">DuckDB</a> (including MotherDuck). You configure your warehouse connection in a config file or pass credentials via environment variables.</p>\n<p>If you want to explore without connecting your own warehouse, point the semantic layer at a sample PostgreSQL database with orders, customers, and products data so you can follow along with the rest of this tutorial. A few thousand rows across three tables is enough to test aggregations, filters, and multi-dimensional queries.</p>\n<h2 id=\"step-2-define-your-metrics\">Step 2: Define your metrics</h2>\n<p>Create a cube that maps to a table in your warehouse and defines the metrics your agents will query.</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    sql_table</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">public.orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    measures</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">amount</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">sum</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    dimensions</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">string</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        sql</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">created_at</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        type</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">time</span></span></code></pre>\n<p><strong>Measures</strong> are the numbers you aggregate: sums, counts, averages. <strong>Dimensions</strong> are the columns you filter and group by: status, date, category. One definition. Every tool, dashboard, and AI agent that queries <code>total_revenue</code> gets the same number.</p>\n<p>You can also define <code>pre_aggregations</code> in the same file to cache expensive computations. For example, a daily rollup of <code>total_revenue</code> by <code>status</code> can cut query times from seconds to single-digit milliseconds. The semantic layer rebuilds these rollups on a configurable schedule and invalidates stale caches automatically.</p>\n<p>Schemas are version-controlled alongside your application code. Review metric changes in pull requests. Roll back a bad definition with <code>git revert</code>. Your data contracts get the same CI/CD workflow as your product.</p>\n<p>To control what’s exposed to specific consumers, define a view:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"yaml\"><code><span class=\"line\"><span style=\"color:#22863A\">views</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  - </span><span style=\"color:#22863A\">name</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">order_metrics</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">    cubes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">      - </span><span style=\"color:#22863A\">join_path</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">orders</span></span>\n<span class=\"line\"><span style=\"color:#22863A\">        includes</span><span style=\"color:#24292E\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">total_revenue</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">count</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">status</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">          - </span><span style=\"color:#032F62\">created_at</span></span></code></pre>\n<p>Views act as a curated interface. Your agents see <code>order_metrics</code> with four fields instead of navigating the full schema.</p>\n<h2 id=\"step-3-deploy-and-connect-your-agent\">Step 3: Deploy and connect your agent</h2>\n<p>Deploy your schema so the semantic layer serves it over an MCP endpoint, then add the MCP server URL to your client’s config:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#24292E\">{</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">  \"mcpServers\"</span><span style=\"color:#24292E\">: {</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">    \"semantic-layer\"</span><span style=\"color:#24292E\">: {</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">      \"type\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"http\"</span><span style=\"color:#24292E\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5\">      \"url\"</span><span style=\"color:#24292E\">: </span><span style=\"color:#032F62\">\"https://your-semantic-layer.example.com/mcp\"</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">    }</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">  }</span></span>\n<span class=\"line\"><span style=\"color:#24292E\">}</span></span></code></pre>\n<p>Paste this into your MCP client’s config file and restart. For Claude Desktop, that’s <code>~/Library/Application Support/Claude/claude_desktop_config.json</code>. For Cursor, it’s <code>.cursor/mcp.json</code> in your project root. Claude Code reads from <code>.mcp.json</code> in your project directory.</p>\n<p>The MCP server handles tool discovery, schema introspection, and query execution over HTTP. Your agent sees available metrics the same way it sees any other MCP tool. No custom integration code required.</p>\n<p>For customer-facing use cases, scope each connection to a specific tenant’s data with row-level security defined in the schema, so a customer’s agent sees only that customer’s rows.</p>\n<h2 id=\"step-4-query-from-your-agent\">Step 4: Query from your agent</h2>\n<p>Once connected, your AI agent can discover and query your metrics using natural language. Behind the scenes, the agent calls MCP tools.</p>\n<p>Ask: “What’s our total revenue this quarter?”</p>\n<p>The agent calls <code>explore_schema</code> to discover available metrics, then calls <code>query</code> with the right measures and time filters. The response comes back as structured data, not raw SQL results.</p>\n<p>Ask: “Break down order count by status for the last 30 days.”</p>\n<p>Same flow. The agent uses <code>query</code> with <code>count</code> as the measure, <code>status</code> as the dimension, and a date filter on <code>created_at</code>.</p>\n<p>A semantic layer typically exposes a handful of MCP tools:</p>\n<ul>\n<li><strong><code>explore_schema</code></strong>: Discover available cubes, measures, and dimensions</li>\n<li><strong><code>query</code></strong>: Fetch aggregated data using governed metric definitions</li>\n<li><strong><code>sql_query</code></strong>: Run queries for cases that need custom SQL (still governed by access controls)</li>\n<li><strong><code>describe_field</code></strong>: Get metadata about a specific measure or dimension</li>\n</ul>\n<p>Every query runs through the semantic layer. The agent never touches raw tables. Read more about the architecture in our <a href=\"/agentic-analytics\">agentic analytics</a> guide.</p>\n<h2 id=\"charting-the-agents-results\">Charting the agent’s results</h2>\n<p>A governed query returns rows. The agent usually needs to show the user a chart, not a wall of numbers. Leaving the model to draw HTML puts the most important part of the answer in its hands to improvise.</p>\n<p><a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>@bonnard/mcp-charts</code></a> fixes that. It adds a <code>visualize</code> tool to your MCP server in a few lines:</p>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6F42C1\">npm</span><span style=\"color:#032F62\"> install</span><span style=\"color:#032F62\"> @bonnard/mcp-charts</span></span></code></pre>\n<pre class=\"astro-code github-light\" style=\"background-color:#fff;color:#24292e; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#D73A49\">import</span><span style=\"color:#24292E\"> { addCharts } </span><span style=\"color:#D73A49\">from</span><span style=\"color:#032F62\"> \"@bonnard/mcp-charts\"</span><span style=\"color:#24292E\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// your data, your connection. Bonnard never touches the database</span></span>\n<span class=\"line\"><span style=\"color:#6F42C1\">addCharts</span><span style=\"color:#24292E\">(server, { runSql });</span></span></code></pre>\n<p>The agent calls <code>visualize</code> with a query, your <code>runSql</code> returns the rows, and Bonnard infers the chart from the typed result, then renders an interactive widget in Claude or ChatGPT. It renders line, bar, area, pie, scatter, funnel, waterfall, and table, with bar variants for stacked, grouped, horizontal, and 100% stacked. The chart comes from your query result, not from tokens the model invents. Same data, same chart, every time. Full walkthrough: <a href=\"/blog/mcp-charts\">MCP Charts</a>.</p>\n<h2 id=\"from-raw-sql-to-governed-metrics\">From raw SQL to governed metrics</h2>\n<p>You started with a warehouse and an AI agent that writes its own SQL. Now you have governed metrics defined in YAML, exposed via MCP, queryable from any AI tool your team or customers use. Setup took under 30 minutes.</p>\n<p>The shift from raw SQL to governed metrics pays off immediately: consistent numbers across every consumer, row-level access control per tenant, and a full audit trail for every query. As your team adds more agents and surfaces, the semantic layer scales with you. No duplicate logic. No drift between dashboards and AI answers.</p>\n<p>Give the agent a way to chart what it queries: <code>npm install @bonnard/mcp-charts</code>, call <code>addCharts(server, { runSql })</code>, and interactive charts render in any MCP Apps client. The repo is on <a href=\"https://github.com/bonnard-data/mcp-charts\">GitHub</a>. Read the <a href=\"/blog/mcp-charts\">MCP Charts guide</a> to go deeper.</p>\n<h2 id=\"related-reading\">Related reading</h2>\n<ul>\n<li><a href=\"/glossary/mcp\">What is MCP?</a> – the protocol that powers agent-to-data connections</li>\n<li><a href=\"/blog/mcp-charts\">MCP Charts</a> – add a <code>visualize</code> tool so agents chart query results in any MCP Apps client</li>\n<li><a href=\"/integrations/snowflake\">Snowflake integration</a> – connect to your Snowflake warehouse</li>\n</ul>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n<h3 id=\"do-i-need-to-retrain-my-ai-model\">Do I need to retrain my AI model?</h3>\n<p>No. A semantic layer with MCP support works with any MCP-compatible AI agent out of the box. Your agent discovers available metrics through the MCP protocol at runtime. There is no fine-tuning, prompt engineering, or model modification required.</p>\n<h3 id=\"what-data-warehouses-does-this-work-with\">What data warehouses does this work with?</h3>\n<p>A semantic layer connects to BigQuery, Snowflake, Redshift, Databricks, PostgreSQL (including Supabase, Neon, and RDS), and DuckDB (including MotherDuck). For charting the agent’s results, <code>@bonnard/mcp-charts</code> ships native adapters for Postgres, BigQuery, Snowflake, Databricks, and DuckDB, or you pass your own <code>runSql</code>. Bonnard never touches your database.</p>\n<h3 id=\"how-is-this-different-from-text-to-sql\">How is this different from text-to-SQL?</h3>\n<p>Text-to-SQL lets an AI agent generate arbitrary SQL from natural language. It has no governance, no consistency guarantees, and no access control. A semantic layer defines metrics once in YAML and exposes them as governed APIs. Every consumer gets the same answer because the calculation is fixed, not interpreted per query. And when the agent charts the result, <code>@bonnard/mcp-charts</code> renders from the query result, not from tokens the model invents.</p>\n<h3 id=\"how-do-i-let-the-agent-show-charts\">How do I let the agent show charts?</h3>\n<p>Install <a href=\"https://www.npmjs.com/package/@bonnard/mcp-charts\"><code>@bonnard/mcp-charts</code></a> and call <code>addCharts(server, { runSql })</code> on your MCP server. That registers a <code>visualize</code> tool. The agent calls it with a query, your callback returns the rows, and an interactive chart renders in Claude or ChatGPT. No frontend code.</p>\n<h3 id=\"how-long-does-setup-take\">How long does setup take?</h3>\n<p>Under 30 minutes for most teams. Stand up a semantic layer, connect your warehouse, define a few metrics in YAML, and deploy it over MCP. Add charts with <code>npm install @bonnard/mcp-charts</code> and one <code>addCharts</code> call. The tutorial above walks through each step with working code examples.</p>\n",
      "date_published": "2026-03-13T00:00:00.000Z",
      "date_modified": "2026-06-25T00:00:00.000Z",
      "authors": [
        {
          "name": "Bonnard Team"
        }
      ]
    }
  ]
}