Dashboard Management

SkillDatabases & data

Once added, your AI can create, organize, share, and edit analytics dashboards on your behalf. The skill gives it the know-how for how dashboards are stored and changed, including arranging panels, using folders, and applying updates safely. Changes come with undo history, so edits can be reversed if needed.

Available today. Use it from your connected AI after setup.

After adding it, ask your AI to create a new dashboard or reorganize your existing ones into folders. You can also have it update an older dashboard to current settings.

Then ask your AI: use the Dashboard Management skill

What your AI can do with it

  • Create new analytics dashboards
  • Organize dashboards into folders
  • Share dashboards with others
  • Edit dashboard panels and layouts
  • Update dashboards safely, with undo history to reverse changes
  • Bring older dashboards up to current settings

What this skill tells your AI

The instructions your AI receives, as published by builderio/agent-native in templates/analytics/.agents/skills/dashboard-management/SKILL.md and read by ahel’s review.

Dashboards are the canonical SQL-backed Analytics resources. New dashboards, saved analyses, reports, and bespoke extensions are represented as dashboard artifacts—not as separate user-facing Analytics objects. Legacy analysis tables and actions remain available only for compatibility.

Storage

Current storage:

TablePurpose
dashboardsExplorer and SQL dashboard records
dashboard_foldersPersonal and shared SQL dashboard folders
dashboard_viewsSaved filter presets per dashboard
dashboard_sharesStandard framework share grants
dashboard_folder_sharesStandard folder share grants
dashboard_revisionsBounded dashboard history snapshots
analysesSaved ad-hoc analysis records
analysis_revisionsBounded analysis history snapshots
analysis_sharesStandard framework share grants for analyses

Legacy settings keys such as u:<email>:dashboard-*, u:<email>:sql-dashboard-*, o:<orgId>:sql-dashboard-*, and adhoc-analysis-* are still read as a fallback and copied into SQL on access. Do not create new dashboard settings rows.

Dashboard folders

Dashboard folders are SQL-backed, access-scoped containers for organizing SQL dashboards from /dashboards:

  • personal folders are private to their owner.
  • shared folders are organization-visible and require an active organization.
  • Use list-dashboard-folders, create-dashboard-folder, and set-dashboard-folder for folder reads and membership changes.
  • A folder never expands dashboard access. A personal folder can contain only an owned private dashboard; a shared folder can contain only an org-visible dashboard. Use the normal dashboard sharing actions separately.

For organization-wide consolidation, use migrate-analytics-artifacts first with dryRun: true. The write requires an organization owner/admin and the exact confirmation token MIGRATE_ANALYTICS_ARTIFACTS. It materializes organization-scoped legacy settings, creates dashboard blocks for saved analyses and standalone extensions, archives exact duplicates, copies shares, and removes legacy settings keys only after SQL rows are materialized. Source rows remain recoverable. Private member-only rows are intentionally outside this organization-scoped operation.

Use mutate-dashboard for existing dashboard edits. It resolves the current user/org context, validates the resulting config, writes the SQL-backed record, syncs collab, and returns compact proof. Use update-dashboard for new full-config saves, UI full-config saves, or explicitly requested low-level JSON-pointer edits.

Every meaningful dashboard save snapshots the previous state into dashboard_revisions. Use list-dashboard-revisions to inspect available undo points and restore-dashboard-revision to restore one; restore snapshots the current state first, then syncs open dashboard editors.

Saved analyses follow the same undo model with analysis_revisions, list-analysis-revisions, and restore-analysis-revision.

Never use db-patch, raw SQL, or settings-key edits to create or modify a dashboard config. Those bypass the dashboard action's access checks, SQL validation, collab sync, and proof-of-done return. If a dashboard action fails because the argument shape was wrong, fix that action's arguments and retry once — do not switch to db-patch or raw SQL.

Valid Panel Sources

panel.source is a backend selector, not a table name. It must be one of:

SourceQuery shape
bigqueryLiteral warehouse SQL. Table names belong inside the SQL string.
ga4JSON descriptor for the Google Analytics Data API.
amplitudeJSON descriptor for an Amplitude query.
first-partyRead-only SQL over this template's analytics_events table, usually via query-agent-native-analytics.

Do not use app-db as a dashboard source. For first-party events collected through /track, use source: "first-party" or the query-agent-native-analytics action rather than raw internal db-query.

AI-generated first-party panels are dashboard-time-bound by default. Set config.timeScope to "dashboard" and include the matching dashboard time filter in the SQL. The allowed values are:

  • dashboard: use the dashboard-selected time range; the default for ordinary metrics.
  • fixed-window: use an explicit bounded window independent of the dashboard filter.
  • cohort-history: use the bounded history of an explicitly defined cohort.
  • all-time: scan all available history; use only when the user requests it and put all-time, lifetime, or historical in the title or description.

{{timeRange}} requires an explicit matching filters entry with id: "timeRange" and type: "select". {{<id>Start}} and {{<id>End}} require a matching filters entry with that id and type: "date-range". Do not rely on undeclared time variables. Server validation rejects unbound first-party SQL, so declare the filter or choose an explicit non-dashboard scope before saving.

A bound anywhere in the SQL is not the same as every CTE having its own bound. If a panel has multiple top-level CTEs (WITH a AS (...), b AS (...)) and more than one of them reads analytics_events, EVERY one of those CTEs needs its own {{timeRange}}/{{<id>Start}}/{{<id>End}} reference or literal date bound — not just the final SELECT or one sibling CTE. A CTE that computes something like "this user's first-ever active day" by scanning analytics_events with no bound at all will full-table-scan on every render even though the panel looks time-bound overall (root cause of a 2026-07-25 production incident: several dashboards had exactly this shape). Server validation checks each top-level CTE independently now, so this fails at save time — but write it right the first time: bound every CTE, or use config.timeScope: "cohort-history" only for a CTE that is genuinely defining a cohort (e.g. a first-seen date), never as a way to skip bounding an ordinary activity scan.

Creating A Dashboard

When the user asks for a dashboard:

  1. Read the injected <data-dictionary> block first (catalog-first). If relevant entries exist, use their table, columns, queryTemplate, and gotchas verbatim.
  2. If a metric definition, date range, or grain is ambiguous and the choice would change the panel's numbers, use the ask-question clarifying tool once before building. Skip it when the dictionary or the user already settled it.
  3. If a metric is not documented, do not guess column names. Ask for the table/columns or introspect the provider schema, then propose a dictionary entry with save-data-dictionary-entry.
  4. Build a complete SqlDashboardConfig with name and panels. Optionally set top-level columns (1–6, default 2) to control how many grid columns the panels before any section use.
  5. Every panel needs id, title, source, chartType, width, and sql. width is the number of grid columns the panel spans (1..6, clamped to the active section's column count). Section panels skip source and sql and may set their own columns (1–6) to override the dashboard default for the panels following the section. Extension panels (chartType: "extension") also skip source and sql; use config.extensionId for ordinary author-selected shared embeds. Use config.extensionSlotId only when the user explicitly asks for a personal/per-viewer slot (see "Embedding An Extension As A Panel").
  6. Persist with update-dashboard, not raw SQL or settings writes.
  7. Navigate to it with pnpm action navigate --view=adhoc --dashboardId=<id>.

An explicit dashboard request authorizes the complete non-destructive build in the same turn. After querying or scaffolding, continue through extension-data seeding/refresh, dashboard save/embed, and navigation; do not ask whether to proceed or leave an empty Custom Block shell. Ask only when metric scope/grain is materially ambiguous, the change is destructive, or it has an external side effect such as sending email or outreach.

Layout is always 1 column when the available content width is below the md threshold (panels stack), then expands to the configured column count at/above it. The grid uses a container query, so it also stacks when the agent sidebar narrows the content pane — not only at narrow viewports. So picking 3 or 4 columns is fine — the renderer keeps narrow layouts readable automatically.

pnpm action update-dashboard --dashboardId weekly-metrics --config '<full json>'
pnpm action navigate --view=adhoc --dashboardId=weekly-metrics

The save path dry-runs BigQuery panels before persisting. If validation returns a provider error, fix the query and retry. Never work around validation by writing directly to a table.

Dual-Axis Charts

line, area, and bar panels can plot series against two y-axes. Reach for this whenever series share an x-axis but not a unit — a count next to a rate, or revenue next to a conversion percent. On a single axis the smaller series flattens into the baseline and reads as "no data."

{
  "id": "signups-vs-conversion",
  "title": "Signups vs conversion rate",
  "source": "first-party",
  "chartType": "line",
  "width": 1,
  "sql": "SELECT day, signups, conversion_rate FROM ...",
  "config": {
    "timeScope": "dashboard",
    "xKey": "day",
    "yKeys": ["signups", "conversion_rate"],
    "yFormatter": "number",
    "rightYKeys": ["conversion_rate"],
    "rightYFormatter": "percent"
  }
}
  • rightYKeys names series from yKeys; everything unnamed stays on the left.
  • rightYFormatter defaults to yFormatter when omitted.
  • Each axis is labelled with its series names (up to two per side), and tooltip values use the formatter of the axis the series belongs to.
  • At least one series must remain on the left. If rightYKeys names every series, or names a column the query never returned, the panel falls back to a single axis and shows a config warning rather than dropping the series.
  • Scheduled email reports render the same two scales, so a dual-axis panel is safe to put on a subscribed dashboard.

Reusable Native Dashboard Patterns

The recent extension-backed dashboards in Builder Analytics cluster into a few repeatable compositions. Prefer these native panels, with a real SQL or Data Program result behind each one, when creating a replacement or a new dashboard:

PatternNative composition
Customer ROI / value realizationmetric KPI cards, line or area trends, table detail, and callout or section panels for the business narrative
Account engagement / outreachmetric coverage and adoption cards, a daily line trend, heatmap or table segmentation, and callout alerts
GTM pipeline / cross-sellfunnel for ordered stages, metric totals, bar or line trends, and a table for account-level follow-up
Win/loss analysissection groups with metric, table, bar, callout, and trend panels; use a Data Program for provider joins and evidence rows

Funnel panels use config.xKey for the stage label and config.yKey for a non-negative count or value. The renderer preserves the SQL row order, shows each stage's share of the first stage, and shows the change from the previous stage. Keep the intended stage order in SQL with ORDER BY.

When a dashboard is being migrated from an extension, create a new v2 copy, bind its panels to the real provider schema or Data Programs, and compare it with the original before retiring the extension-backed version. Do not invent customer-specific SQL, provider joins, cached rows, or extension ids in a catalog template. Existing dashboards remain readable while the native replacement is validated. Bespoke interaction flows, arbitrary layouts, and visualizations outside these contracts may remain Custom Blocks.

The source tree ships four provider-free v2 manifests in server/lib/native-v2-dashboards.ts: Customer ROI, Account Engagement, Cross-sell, and Win / Loss. They intentionally contain no customer names, provider ids, SQL, cached rows, or guessed joins. After deployment, an organization owner or admin provisions them with ensure-native-v2-dashboards by supplying one real Data Program per binding key. The action validates the stored program output contract, shares the programs with the organization, creates deterministic native-*-v2-* dashboard copies, and preserves the extension-backed originals and any existing v2 edits. Do not add these to the root demo bootstrap or silently auto-bind them to guessed provider schemas.

When To Use An Extension Instead

Native Analytics dashboards are JSON configs rendered by the built-in dashboard components. Use native dashboard actions only when the request fits that model: standard panels, supported chart types, filters, variables, sections, and grid layout. Dual-axis charts are part of that model — build one with config.rightYKeys, never as an extension.

If the user asks for a dashboard or analytical surface that needs bespoke UI or code beyond the dashboard JSON/component model, create an extension and embed it in the dashboard. Examples include custom interaction flows, non-standard visualizations, complex multi-step workflows, highly custom layouts, custom client-side state, or a dashboard-like app that needs behavior the built-in renderer cannot express. In production mode, call create-extension automatically, then call update-dashboard with one or more chartType: "extension" panels using config.extensionId. Never leave the extension as a standalone Analytics result or direct the user to the Extensions page.

Embedding An Extension As A Panel

Use chartType: "extension" to add an extension box alongside normal SQL charts. The panel skips source and sql. For ordinary requests such as "put X in this dashboard," save the author-selected extension id in config.extensionId. This makes the selection part of the shared dashboard and keeps the widget present in scheduled report captures:

{
  "id": "pipeline-widget",
  "title": "Pipeline Widget",
  "chartType": "extension",
  "width": 3,
  "config": { "extensionId": "extension-123" },
}

Direct embeds receive the dashboard id, name, description, current filters, and panel context. Embedding does not grant extension access, so share the extension with the dashboard audience.

Use a stable config.extensionSlotId only when the user explicitly wants each viewer to choose or install their own widget:

analytics.dashboard.<dashboard-id>.panel.<panel-id>

Create or choose the extension, call add-extension-slot-target with the extension id and slot id, then call install-extension with the same values. The dashboard panel is shared, while the installed extension is per-user. Empty slots show the normal install affordance instead of a broken iframe.

{
  "id": "pipeline-widget",
  "title": "Pipeline Widget",
  "chartType": "extension",
  "width": 3,
  "config": {
    "extensionSlotId": "analytics.dashboard.weekly-metrics.panel.pipeline-widget",
  },
}

Notes:

  • Both direct and slot-backed extensions receive dashboard and panel context.
  • Installs and extension access are per viewer. Sharing the dashboard does not automatically install or grant access to its extension for other viewers.
  • Slot installs are per-user preferences. Different viewers can see different widgets, and scheduled reports running as a service identity may show an empty slot. This is why slots are opt-in rather than the default.

Cloning A Direct-Extension Dashboard (e.g. per-customer copies)

When the user asks for a copy of an existing extension-backed dashboard for a different customer/org (for example "make an Intuit version of the Roku usage dashboard"), follow this playbook. Extension bodies are frequently tens of thousands of characters. The reliable path is to read+transform+write the body INSIDE run-code (where workspaceRead returns the full file) and then create from that written file — never by pulling the body into chat context first or re-typing it as a content argument.

  1. get-sql-dashboard with includeConfig: true on the source dashboard and confirm the target panel is a chartType: "extension" panel with config.extensionId; grab that extension id. For a slot-backed panel, clone the dashboard panel with a new stable extensionSlotId, then target and install the desired extension into that slot instead of using this body-copy playbook.
  2. get-extension for that id with forceContent: true exactly once. Reuse that body for the rest of the turn — a second same-run read intentionally omits content and returns contentOmitted instead. That is not the content disappearing; use the copy you already have. Do NOT try to re-fetch the body with run-code (appAction('get-extension')) to page past a display truncation — the same-run omit makes it return empty content, wasting turns. If you need the full body again, read the workspace resource file (step 5) or set forceContent: true on a single native get-extension.
  3. Change ONLY the small customer-specific static config (e.g. the ACCOUNT_USAGE_STATIC block: company name, title, org-discovery filters, messaging). Prefer a focused update-extension edit/patch over regenerating the entire HTML.
  4. Call create-extension / update-extension as native tools. They are mutating actions and are NOT callable from run-code / appAction (the sandbox bridge only exposes read-only actions). Do not try to create or update an extension from inside run-code.
  5. If the source body already exists as a workspace/shared resource file (e.g. a pre-built intuit-analytics-extension.html), do the read AND the customer swap in ONE run-code call, then create from the written file:
    • Inside run-code: const src = await workspaceRead('<source>.html') returns the WHOLE file (it auto-pages; there is no 50k cap here), do the small string-replace on the static config block, then await workspaceWrite('<target>.html', modified).
    • Then call create-extension (native) with contentFromWorkspaceFile: '<target>.html' and leave content empty — the server reads the full file verbatim. Do NOT read the source body with the resources read tool (or get-extension) first just to transform it: that display is capped and wastes a turn. And do NOT re-emit an 80k+ char body as the content argument — it gets cut off mid-stream. contentFromAttachment only sees files the user pasted into chat, not workspace resources. create-extension/update-extension are mutating and cannot run from run-code, so only the read+write+transform happens there.
  6. Finally update-dashboard to save a new dashboard embedding the new extension panel (chartType: "extension", config.extensionId), then navigate to it.

Repairing An Existing Extension-Backed Dashboard

When the user asks to fix data loading in an existing or migrated extension-backed dashboard, treat the current extension body as user-authored design. Read the dashboard config and extension once, identify the smallest data-loading seam, and call update-extension with focused patches or edits. Preserve the existing layout, CSS, copy, and interactions. Do not send a reconstructed full content body for a data-only repair; update-extension blocks full-body replacement unless allowFullReplacement: true is explicitly supplied. Use that flag only for a user-requested broad visual rewrite or a complete replacement body supplied by the user. If a focused edit fails, inspect the current body and change the target rather than retrying the same arguments.

Display truncation is cosmetic — do not chase the "missing" tail

A tool result ending in ...[truncated — full result was N chars; only first 50,000 shown] (from the resources read tool or get-extension) means only the DISPLAYED text was capped. The file is intact. run-code's workspaceRead returns the full N chars, and contentFromWorkspaceFile hosts the full file. Never read the same file twice or try to "page the rest" to recover the tail — that is the single biggest source of wasted turns on clone requests. Decide to clone, then go straight to the run-code read+transform+write path in step 5.

Config Shape

{
  "name": "Weekly Metrics",
  "description": "Core product and acquisition metrics",
  // Default grid columns for panels before any section. 1–6, default 2.
  // The grid is always 1 column on small screens and expands at `md:`.
  "columns": 3,
  "filters": [
    {
      "id": "date",
      "type": "date-range",
      "label": "Date Range",
      "default": "30d",
    },
  ],
  "variables": {
    "EVENTS": "`my_project.analytics.events`",
  },
  "panels": [
    // 3 metric cards sit side-by-side at md+ thanks to the dashboard's "columns": 3.
    {
      "id": "kpi-clicks",
      "title": "Clicks",
      "source": "first-party",
      "chartType": "metric",
      "width": 1,
      "config": { "timeScope": "dashboard" },
      "sql": "SELECT COUNT(*) AS value FROM analytics_events WHERE event_name = 'click' AND event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}'",
    },
    {
      "id": "kpi-signups",
      "title": "Signups",
      "source": "first-party",
      "chartType": "metric",
      "width": 1,
      "config": { "timeScope": "dashboard" },
      "sql": "SELECT COUNT(*) AS value FROM analytics_events WHERE event_name = 'signup' AND event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}'",
    },
    {
      "id": "kpi-active",
      "title": "Active users",
      "source": "first-party",
      "chartType": "metric",
      "width": 1,
      "config": { "timeScope": "dashboard" },
      "sql": "SELECT COUNT(DISTINCT user_id) AS value FROM analytics_events WHERE event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}'",
    },
    // Section header switches the grid to 2 columns for the panels below it.
    {
      "id": "trends",
      "title": "Trends",
      "chartType": "section",
      "width": 1,
      "columns": 2,
    },
    {
      "id": "events",
      "title": "Events",
      "source": "first-party",
      "chartType": "line",
      "width": 2,
      "config": { "timeScope": "dashboard" },
      "sql": "SELECT event_date AS date, COUNT(*) AS value FROM analytics_events WHERE event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}' GROUP BY 1 ORDER BY 1",
    },
  ],
}

Filters And Variables

filters[] defines dashboard-wide controls. Filter values are available in panel SQL through {{var}} interpolation. Date ranges emit {{<id>Start}} and {{<id>End}}.

For dashboard-time-bound first-party SQL, use config.timeScope: "dashboard" and a predicate that consumes the declared filter, such as event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}'. A {{timeRange}} token must have a matching select filter and SQL branches for its options; date variables must have a matching date-range filter. The server rejects unbound first-party SQL during dashboard validation.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
5k
Forks
440
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
dashboard-management
Source
github.com/builderio/agent-native