# Dashboard Management

> How analytics dashboards are stored, created, and modified. Covers SQL dashboard tables, legacy settings migration, folders, valid panel sources, layout shape, and safe update patterns. Use when creating, organizing, sharing, or modifying Analytics dashboards.

- Skill: `builderio/dashboard-management` (Agent Skill)
- Install (CLI): `npx skillmds@latest add builderio/dashboard-management`
- Raw SKILL.md: https://api.skillmd.com/api/skills/builderio/dashboard-management/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: Builder.io (https://skillmd.com/u/builderio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/builderio/dashboard-management

---


# Dashboard Management

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:

| Table              | Purpose                                      |
| ------------------ | -------------------------------------------- |
| `dashboards`       | Explorer and SQL dashboard records           |
| `dashboard_folders` | Personal and shared SQL dashboard folders  |
| `dashboard_views`  | Saved filter presets per dashboard           |
| `dashboard_shares` | Standard framework share grants              |
| `dashboard_folder_shares` | Standard folder share grants          |
| `dashboard_revisions` | Bounded dashboard history snapshots       |
| `analyses`         | Saved ad-hoc analysis records                |
| `analysis_revisions` | Bounded analysis history snapshots        |
| `analysis_shares`  | Standard 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:

| Source        | Query shape                                                                                              |
| ------------- | -------------------------------------------------------------------------------------------------------- |
| `bigquery`    | Literal warehouse SQL. Table names belong inside the SQL string.                                         |
| `ga4`         | JSON descriptor for the Google Analytics Data API.                                                       |
| `amplitude`   | JSON descriptor for an Amplitude query.                                                                  |
| `first-party` | Read-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.

```bash
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."

```json
{
  "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:

| Pattern | Native composition |
| --- | --- |
| Customer ROI / value realization | `metric` KPI cards, `line` or `area` trends, `table` detail, and `callout` or `section` panels for the business narrative |
| Account engagement / outreach | `metric` coverage and adoption cards, a daily `line` trend, `heatmap` or `table` segmentation, and `callout` alerts |
| GTM pipeline / cross-sell | `funnel` for ordered stages, `metric` totals, `bar` or `line` trends, and a `table` for account-level follow-up |
| Win/loss analysis | `section` 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:

```jsonc
{
  "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:

```text
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.

```jsonc
{
  "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

```jsonc
{
  "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.

**Filter ids must be unique.** Two filters with the same `id` collide on the same URL param, so changing one visibly updates the other in the UI. The dashboard save endpoint rejects duplicates with a 400.

**Use `type: "date-range"` for paired start/end dates.** Don't add two `type: "date"` filters labeled "Start" and "End" — even with distinct ids, that ships the wrong UX. A single date-range filter renders as the "from … to …" pair and exposes both halves to SQL via `{{<id>Start}}` / `{{<id>End}}`.

Use `variables` for shared constants such as table refs or project IDs. Identifier-like variables can be used bare (`FROM {{EVENTS}}`); string values should be inside SQL string literals (`'{{author}}'`).

Use conditional blocks for optional filters:

```sql
{{?country}}AND country = '{{country}}'{{/country}}
```

Filters auto-apply on change — there is no Apply button. Each filter change writes to the URL and re-runs the affected panels. Other filters are preserved (the URL update is functional, not destructive). If you see a filter "reset" itself when another filter changes, look for a duplicate `id` first.

## Modifying A Dashboard

For existing dashboard edits, default to `mutate-dashboard`. It gives the
agent a typed mutation API without exposing arbitrary JavaScript execution.
Pass structured `operations` for normal agent calls; keep the legacy `code`
form for short layout/config edits only. Large SQL payloads belong in the
server-side first-party metric catalog, not in a prompt argument.
The server parses only documented `dashboard.*` method calls, applies the
resulting operations in memory, validates the final dashboard config, writes
SQL once, syncs collab, and returns compact proof.

Arguments must be JSON-compatible literals, so quote object keys. Variables,
imports, loops, functions, templates, network, filesystem, DB access, and
calling other actions from the script are not available.

```ts
type DashboardMutationApi = {
  dashboard: {
    set(patch: DashboardPatch): void;
    setFilterDefault(
      filterId: string,
      value: string | number | boolean | null,
    ): void;
    panel(id: string): PanelSelection;
    section(id: string): SectionSelection;
    panels(ids: string[]): PanelSelection;
    panelsMatching(filter: PanelFilter): PanelSelection;
    insertPanel(panel: PanelInput): InsertedPanel;
  };
};

type DashboardPatch = {
  name?: string;
  description?: string;
  columns?: number;
  filters?: unknown[];
  variables?: Record<string, string>;
  parentId?: string;
};

type PanelTimeScope =
  | "dashboard"
  | "fixed-window"
  | "cohort-history"
  | "all-time";

type PanelConfig = Record<string, unknown> & {
  // Use "dashboard" for AI-generated first-party panels by default.
  timeScope?: PanelTimeScope;
  // Fixed bar width in pixels for bar charts.
  // Series to plot against a second, right-hand y-axis. See "Dual-Axis Charts".
  rightYKeys?: string[];
  rightYFormatter?: "number" | "currency" | "percent";
  // Exact series-key to display-label aliases for legends and tooltips.
  seriesLabels?: Record<string, string>;
};

type PanelPatch = {
  title?: string;
  sql?: string;
  source?:
    | "bigquery"
    | "ga4"
    | "amplitude"
    | "first-party"
    | "demo"
    | "prometheus"
    | "program";
  chartType?:
    | "line"
    | "area"
    | "bar"
    | "metric"
    | "table"
    | "pie"
    | "section"
    | "funnel"
    | "heatmap"
    | "callout"
    | "extension";
  width?: number;
  columns?: number;
  tab?: string;
  config?: PanelConfig;
  description?: string;
};

type PanelInput = PanelPatch & {
  id: string;
  title: string;
  chartType: NonNullable<PanelPatch["chartType"]>;
  source?: PanelPatch["source"]; // required for non-section / non-extension panels
  sql?: string; // required for non-section / non-extension panels
  // For chartType "extension": use config.extensionId by default; extensionSlotId is opt-in per-viewer content.
};

type PanelFilter = {
  id?: string;
  ids?: string[];
  idIncludes?: string;
  title?: string;
  titleIncludes?: string;
  chartType?: PanelPatch["chartType"];
  source?: PanelPatch["source"];
  tab?: string;
  isSection?: boolean;
};

type PanelSelection = {
  moveToTop(): void;
  moveToBottom(): void;
  moveBefore(panelId: string): void;
  moveAfter(panelId: string): void;
  moveToIndex(index: number): void;
  moveNextTo(panelId: string): void;
  nextTo(panelId: string): void;
  moveToRow(rowNumber: number): void;
  moveToRowStart(rowNumber: number): void;
  moveToRowEnd(rowNumber: number): void;
  atRow(rowNumber: number): void;
  atRowStart(rowNumber: number): void;
  atRowEnd(rowNumber: number): void;
  remove(): void;
  set(patch: PanelPatch): void;
  setTitle(title: string): void;
  setSql(sql: string): void;
  setWidth(width: number): void;
  setConfig(patch: Record<string, unknown>): void;
  setConfigPath(path: string, value: unknown): void;
  duplicate(newPanelId: string, patch?: PanelPatch): PanelPlacement;
};

type SectionSelection = PanelSelection & {
  append(panelIds: string[]): void;
};

type PanelPlacement = {
  atTop(): void;
  atBottom(): void;
  before(panelId: string): void;
  after(panelId: string): void;
  atIndex(index: number): void;
  nextTo(panelId: string): void;
  atRow(rowNumber: number): void;
  atRowStart(rowNumber: number): void;
  atRowEnd(rowNumber: number): void;
};

type InsertedPanel = PanelPlacement;
```

Examples:

```ts
dashboard.panels(["dau-over-time", "wau-over-time"]).moveToTop();
dashboard
  .panel("recurring-users-by-template")
  .duplicate("recurring-users-by-template-bar", {
    title: "Recurring Signed-In Users by Template (Bar)",
    chartType: "bar",
  })
  .nextTo("recurring-users-by-template");
dashboard.setFilterDefault("emailFilter", "exclude_builder");
dashboard.panel("top-referrers").setTitle("Top Referrers by Domain");
dashboard.panel("retention").set({
  width: 2,
  config: { description: "Updated definition." },
});
dashboard.panelsMatching({ source: "first-party" }).setWidth(2);
dashboard
  .panelsMatching({ titleIncludes: "Revenue" })
  .setConfigPath("yAxis.format", "currency");
dashboard.panelsMatching({ titleIncludes: "Signed-In" }).moveToTop();
dashboard
  .section("retention-activity-section")
  .append(["repeat-users", "retention-over-time"]);
dashboard
  .insertPanel({
    id: "new-kpi",
    title: "New KPI",
    source: "first-party",
    chartType: "metric",
    width: 1,
    config: { timeScope: "dashboard" },
    // Assumes filters includes { id: "date", type: "date-range", default: "30d" }.
    sql: "SELECT COUNT(*) AS value FROM analytics_events WHERE event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}'",
  })
  .atTop();
```

Native tool call:

```json
{
  "dashboardId": "weekly-metrics",
  "code": "dashboard.panels([\"dau-over-time\",\"wau-over-time\"]).moveToTop();"
}
```

Use `update-dashboard` only for new full-config saves, UI full-config saves, or
when the user specifically requests low-level JSON-pointer edits.

`get-sql-dashboard` is compact by default. It returns panel summaries, ids,
titles, chart types, sources, layout groups, `layout.panelOrder`, and
`layout.firstPanelIds` without embedding every panel's full SQL. Use that
compact result to find panel ids and verify order. Pass `includeConfig: true`
only when you need full panel SQL/config for a detailed edit.

After a mutation, navigate to the dashboard if the user is elsewhere. The app syncs through the framework's polling/query invalidation path.

### Reordering Panels

For simple "move this chart/section" requests, prefer `mutate-dashboard` with
structured operations. The short script form is also supported for one compact
move:

```json
{
  "dashboardId": "weekly-metrics",
  "code": "dashboard.panels([\"dau-over-time\",\"wau-over-time\"]).moveToTop();"
}
```

Do not do index arithmetic with `/panels/<index>` unless the user specifically
asks for a low-level JSON-pointer edit. Use `moveBefore`, `moveAfter`,
`moveToTop`, `moveToBottom`, or `moveToIndex` against panel ids instead.

`get-sql-dashboard` returns `layout.panelOrder`, `layout.firstPanelIds`, and
row/group summaries. Use those fields for orientation and verification instead
of re-reading stale screenshots or counting positions from memory.

### Existing Dashboard Edits

When the user asks to change existing panels:

1. Read the current dashboard with `get-sql-dashboard` compact mode unless full
   SQL/config is required.
2. Call `mutate-dashboard` once with every change in `operations`. Use panel
   ids, not shifted array indexes. The short `code` form is only for compact
   layout/config edits; do not stream a large multi-panel SQL script.
3. Verify the returned `panelCount`, `appliedOps`, `firstPanelIds`, and
   `summary`. If possible, read the affected panels back and confirm the exact
   fields changed.

For SQL-only panel edits, use `dashboard.panel("id").setSql("...")`. If the
metric semantics changed, also update the visible definition with
`setConfigPath("description", "...")` or `set({ "description": "..." })`. If
the title, source, chart type, width, or config shape changes together, put them
in the same `set({...})` call.

### First-Party User Metrics

For first-party `/track` events, be precise about identity:

| Metric intent                                | Identity expression                                                                                                                                                                |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Account users, DAU, WAU, retention, cohorts  | `NULLIF(user_id, '')` plus `NULLIF(user_id, '') IS NOT NULL`, but only on events that actually represent the activity being measured                                               |
| Signed-in visitor activity                   | `event_name = 'session status' AND signed_in = 'true'` keyed by `COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))`, labeled as signed-in visitors rather than account users |
| Public traffic, visitors, clip/share viewers | `COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))`                                                                                                                          |

Do not call anonymous visitors "users" in dashboard labels or descriptions.
When a user asks for DAU, WAU, retention, repeat users, or account cohorts,
exclude logged-out traffic unless they explicitly ask for visitor metrics. If
the active/session events do not include account identity, do not substitute
signup or identify events and call that DAU/WAU. Either update instrumentation to
send account identity on active events, or label the dashboard metric as
signed-in visitor activity.

For template/app activity metrics, exclude `docs` from DAU, WAU, retention, and
repeat-user panels. A docs event may carry `signed_in = true` from shared auth
state or tracker context, but docs traffic is not app usage and should not appear
as an app/template series. Use a minimum cohort-size threshold for retention
rates so one or two identities cannot create misleading 100% or 0% spikes.

## Demo Dashboards

`ensure-demo-dashboards` auto-installs a per-user demo on first app open.

## Building Large First-Party Dashboards (compose-dashboard)

For a **first-party analytics** dashboard, prefer `compose-dashboard` over hand-authoring a big `update-dashboard` config. You name the metrics; the SERVER expands each into a full, validated panel (SQL + chart config) from the shipped metric catalog and saves them in ONE atomic call. This avoids the failure mode where the agent must stream a giant multi-panel `update-dashboard` argument inside the ~40s budget — that big tool-call can't be resumed mid-stream and is all-or-nothing on validation, so the agent thrashes (repeated update-dashboard + tool-search, never landing).

- **Never hand-author large first-party configs panel-by-panel.** Call `compose-dashboard` with the metric keys instead.
- Unknown metric keys are skipped and reported in `unknownMetrics` (not fatal). Each panel's SQL is validated independently — valid panels save, invalid ones are reported in `invalidMetrics`.
- By default (no `overwrite`), composing into an existing dashboard APPENDS the new panels and skips ids already present. `overwrite: true` replaces the whole config.
- Set `refreshExisting: true` when refreshing a catalog-backed dashboard. Matching metric panels are replaced in place, unrelated panels and layout order are preserved, and the server returns `refreshedExistingIds`.
- Each metric accepts an optional per-metric `window` of `'30d' | '90d' | 'all'` (only affects windowed virality/time metrics) and `title` / `chartType` / `width` overrides. Request `'all'` only when the user asks for all-time coverage, and describe it as full available history.
- Returns `{ saved, dashboardId, panelCount, createdMetrics, refreshedExistingIds, unknownMetrics, invalidMetrics, skippedExistingIds }` — report `saved: true`, `panelCount`, and any invalid/unknown metrics as proof-of-done.

Available metric keys: `total-signups`, `signups-over-time`, `signups-by-template`, `sessions-by-app`, `sessions-over-time`, `replay-sessions`, `replay-chunks-over-time`, `recent-replay-sessions`, `signed-in-vs-anon`, `total-template-clicks`, `total-demo-clicks`, `total-cli-copies`, `template-interest-over-time`, `clicks-by-template`, `demo-clicks-by-template`, `cli-copies-by-template`, `cli-copies-over-time`, `pageviews-over-time`, `top-referrer-domains`, `referred-signups-30d`, `viral-signup-share-30d`, `clip-share-signups-30d`, `signups-by-referral-source`, `referred-signups-over-time`, `top-referrers`, `share-funnel-30d`, `viral-participation-rate-90d`, `viral-coefficient-90d`, `activated-referrers-90d`.

```bash
# Build a large first-party dashboard in ONE call (server generates the panels)
pnpm action compose-dashboard --dashboardId first-party-overview --title "First-Party Overview" \
  --metrics '["total-signups","signups-over-time","signups-by-template","sessions-by-app","viral-coefficient-90d","top-referrers","share-funnel-30d"]'
```

## Reliable Bulk Edits

This is the dashboard-specific application of the framework-wide `reliable-mutations` skill — read that for the general rule (one atomic write, verify end state, report proof-of-done).

Hosted agent runs have a **~40s budget**. Many sequential `update-dashboard` calls (one per panel, plus schema-discovery calls) will blow that budget and leave the dashboard in a partial state — earlier inserts looked like they succeeded (✓), but nothing actually persisted. Avoid this:

- **For a large first-party dashboard, use `compose-dashboard`** (see the section above): name the metrics, the server generates the panels in one call. Do not hand-author the big config.
- **Batch ALL edits into ONE `mutate-dashboard` call.** Structured operations can move,
  insert, remove, duplicate, and update many panels. Never loop dashboard edit
  actions panel-by-panel.
  - To bulk edit existing panels, use selectors:
    `dashboard.panelsMatching({"source":"first-party"}).setWidth(2);`
  - To make nested config edits, use
    `setConfigPath("yAxis.format", "percent")` instead of resending/clobbering
    the whole nested object.
- **Always verify the returned proof-of-done and report it.**
  `mutate-dashboard` returns `panelCount`, `appliedOps`, `panelOrder`,
  `firstPanelIds`, `changedPanelIds`, `commandLog`, and a `summary` string.
  Tell the user the resulting panel count instead of assuming success.

```bash
# Add or edit several panels in ONE atomic call (never one call per panel)
pnpm action mutate-dashboard --dashboardId weekly-metrics \
  --code 'dashboard.panelsMatching({"source":"first-party"}).setWidth(2);'

```

## Archiving vs deleting

Dashboards have a soft-delete state. The default user-facing destructive action 

…(truncated)
