There is a moment in every analytics integration where somebody proposes an iframe, everybody agrees it is unsatisfactory, and it ships anyway because the alternative was six weeks of work. This is about making the alternative cheap enough that the iframe never gets proposed.

Why an iframe is worse than it looks

The visible cost is cosmetic: another product's fonts, spacing, colours and loading spinner inside your page. Teams accept that. The costs they discover later are the ones that matter.

  • The data cannot be reached. Your application can display the chart but cannot read the number — so it cannot put it in a heading, drive a rule from it, or include it in an export.
  • Interaction stops at the border. A filter in your application does not reach inside the frame, and a click inside it does not reach out.
  • Two identity systems. Whatever you do with tokens, there are now two notions of who the user is, and they must be kept in step.
  • Their release cadence is now yours. A change on their side appears in your product without passing your QA.

The alternative: a specification, not components

InsightCompose hands a consuming application two things and nothing else: a layout describing what to draw, and rows to draw it from. The application renders them with its own components.

Critically, the specification carries no colours, no fonts and no pixel sizes. Geometry is grid cells. Formatting is semantic — currency, not #e97627. Chart kind is bar, not a chart library's config object.

sequenceDiagram
    autonumber
    participant App as Your React app
    participant IC as InsightCompose
    participant W as Your warehouse

    App->>IC: GET /v1/dashboards/regional-performance
    IC-->>App: Layout — panels, chart specs, filter definitions
    Note over App: Render the filter bar and panel frames
with your own components App->>IC: POST /v1/dashboards/regional-performance/results IC->>W: One query per panel, in parallel W-->>IC: Rows IC-->>App: Rows per panel + provenance Note over App: Draw each panel in your design system App->>IC: POST .../results (filters changed) IC-->>App: Rows per panel
The layout is fetched once and held. Only data moves after that, which is what keeps a filter change feeling instant rather than like a page load.

What the layout actually contains

It is deliberately self-describing, so nothing has to be hard-coded on your side.

{
  "columns": 12,
  "results_endpoint": "/api/v1/dashboards/regional-performance/results",
  "filters": [
    { "name": "segment", "label": "Segment", "type": "enum",
      "multi": true, "values": ["ENTERPRISE", "MIDMARKET", "SMB", "PUBLIC"],
      "default": ["ENTERPRISE", "MIDMARKET"],
      // which panels this control actually changes
      "applies_to": ["revenue", "cost", "trend"] }
  ],
  "panels": [
    { "id": "revenue", "type": "chart", "title": "Revenue by region",
      "chart": { "kind": "column", "x": "region", "y": ["revenue"],
                  "format": { "revenue": "currency" } },
      "layout": { "x": 0, "y": 2, "w": 6, "h": 8 },
      // clicking a bar sets this filter — cross-filtering, as data
      "emits": [{ "on": "mark", "column": "region", "filter": "region" }],
      "query": { "slug": "revenue-by-region", "version": 3,
                 "columns": [{ "name": "region", "type": "string" },
                              { "name": "revenue", "type": "decimal" }] } }
  ]
}

Three details there earn their place.

applies_to — which panels a filter reaches

A filter bar in a host application usually has to be built by hand because nobody knows which controls affect which panels. Here the platform works it out and says so, so the bar can render itself — and a control that reaches nothing is caught before publishing rather than sitting on screen doing nothing.

emits — cross-filtering expressed as data

Clicking a bar means "narrow everything to this category". In this model that is not a special interaction: it adds a value to the same filter payload every panel already receives. A panel whose query does not declare that parameter simply ignores it.

So your application implements cross-filtering by reading emits, adding the clicked value to its filter state, and asking for rows again — the call it already makes. There is no second protocol.

columns and version — the shape, before you ask for data

Your components know what the rows will look like before the first request, and the version pins them. Republishing the underlying query does not silently change what your application receives.

Integrate
The integrate tab of a dashboard showing the layout and results endpoints with copy buttons, and a JavaScript snippet showing the two fetch calls
The two calls, copy-pasteable, on the dashboard itself. Along with the reminder that a key is minted under Access and told what it may call under Catalog → Keys & grants — a key with no grant can call nothing.

One filter payload, many panels

The results call takes a single filter object and returns a result per panel. Each panel takes the filters its query declares and ignores the rest, which is what lets one filter bar serve a page where each panel filters on part of it.

Two properties of that endpoint are worth knowing about when you design your screen:

PropertyWhy it is that way
A failing panel is reported in placeSeven working panels and one that says why beats one error that loses the seven
You may request a subset of panelsRefresh one tile without re-querying the page
Panels resolve in parallel server-sideA page costs about its slowest panel, not the sum of all of them
Every result carries its provenanceVersion, filters as applied, freshness, cache state — so your UI can show it if it should

A note on concurrency

Our own console fetches each panel separately so the page fills in progressively rather than waiting for the slowest query. Browsers cap concurrent requests to one origin at around six, which is the behaviour you want — the warehouse sees a steady handful rather than eighteen at once. If you prefer a single round trip, ask for all panels in one call; they still resolve in parallel on the server.

What you do not have to build

The point of the split is that the hard parts stay on our side of the line.

YOUR SIDEPresentation and state

Render a chart kind with your components. Hold filter state. Decide what a click does. That is it.

OUR SIDEEverything about the number

The query, its parameters and bounds, entitlement per caller, version pinning, caching, row caps, timeouts, and the log of who asked for what.

The migration path this enables

For a team currently embedding a BI tool, the sequence that works is not a big-bang replacement. It is:

  1. Add insight where there is none. Put the AI narrative or a KPI into a screen that has no analytics today. Low risk, visible value, and it proves the integration.
  2. Shadow one existing dashboard. Rebuild it as data APIs and render it natively next to the embedded original. Compare the numbers for a fortnight.
  3. Swap the frame for components. When the numbers agree, remove the iframe. The screen is now yours — your design system, your interaction model, your release cadence.
  4. Let the APIs outlive the dashboard. The queries are now addressable by anything, which is the position you actually wanted to be in.

Update, October 2026

The layout call now also serves dashboards that take a required parameter (a broker group a consumer must pass on every call), a panel can be an insight part with its narrative, and grants can be given to an Okta or Entra group as well as to a key. See Describe a dashboard and A catalog with a ledger.

Read next

Your dashboards are a dead end for AI — the strategic case, for the architecture review.