- name
- dashboard-management
- description
- Create, lay out, organize, or share Analytics dashboards and panels: sources, placement, folders, verification. Use to create a dashboard or to move, reorder, or lay out panels; a small edit of one existing panel needs no skill.
# 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 a compact result with a `verified` flag. 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.
Each revision's `changes` names what the save that left it behind changed
(`panel-added`, `column-resized`, `filter-changed`, `version-restored`, and so
on), so restoring it undoes that change and every later one. To answer "undo
the column I widened", pick the revision whose `changes` match instead of
guessing from times. `changesStatus` is `absent` for revisions saved before
changes were recorded and `unreadable` when the stored list is corrupt; say you
can't tell what those changed rather than treating them as empty.
On the page, an editor's own saves show a toast that names the change its Undo
takes back, says "Saved for everyone" (org or public visibility; "Saved" when
private), and counts the others who have the dashboard open. That Undo
restores the revision its save left (a full-config `update-dashboard` save
returns it as `revisionId`), but only while the dashboard is as that save left
it: once someone else has saved, it fails rather than taking their change back.
For "undo my last change" in chat, restore the newest revision from
`list-dashboard-revisions`. The History panel shows each revision's first
change with "+N more".
Saved analyses follow the same undo model with `analysis_revisions`,
`list-analysis-revisions`, and `restore-analysis-revision`.
Dashboards open in View mode. An editor switches the header toggle to Edit
before drags, renames, and column edits on the page save for everyone; the chat
context reports the current mode. A table column width dragged in View mode is
stored only in that person's browser, so "widen this column for me" is a drag
in View mode, not a dashboard change. `config.columns[].width` is the shared
width everyone sees; set it only when asked to change the dashboard itself.
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 verification. 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.
### GitHub folder sync
A linked folder mirrors its SQL dashboards to `<path>/<dashboardId>.json` in a
GitHub repo. Read `docs/dashboard-github-folder-sync.md` before changing or
explaining it.
- Run `preview-dashboard-folder-github-sync` before any sync. It writes nothing.
- `apply-dashboard-folder-github-sync` pulls from GitHub. It writes only the
panels, order, and settings that changed on the GitHub side.
- `export-dashboard-folder-to-github` opens one PR with the app's changes. It
refuses while an earlier export PR is still open.
- `configure-dashboard-folder-github-sync` links or unlinks a folder. It stores
no credentials.
- A unit changed on both sides is a conflict. It is reported and not applied.
Do not resolve it by overwriting either side without asking the user.
- Explorer dashboards, sharing, and deletions never sync. A synced dashboard
cannot be permanently deleted; archive it or unlink its folder first.
## 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. Start from the preloaded `<resource scope="analytics-catalog">` references (catalog-first), else one `find-data`. If a relevant entry exists, use its `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 and do not ask the user for them. Find the table and columns with `search-bigquery-schema` (or the provider's own schema action), 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` (load it with `tool-search` — it is not on the
initial tool surface), not raw SQL or settings writes. For a large first-party
dashboard, load `compose-dashboard` the same way in one `tool-search` call.
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.
### Replicating Or Adapting Another Dashboard
When the user asks to replicate, clone, or adapt an existing dashboard into a new
one, call `search-dashboard-references` with focused terms first. It searches
accessible active saved dashboards (ids, names, descriptions, serialized config),
including legacy ones. Inspect each result with `get-sql-dashboard` when `kind`
is `sql`, or `get-explorer-dashboard` when it is `explorer`. A result is a
reference, not proof its source is authoritative for the new request: check the
provider and scope the user asked for before copying its source semantics, and do
not route a replication request to first-party Analytics by default. This applies
to a new dashboard, not to editing the dashboard that is already open.
## 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.
## Key-Date Event Markers
Mark a launch, release, or incident on a `line`, `area`, `bar`, or `combo`
chart with a date x-axis using `config.events`. Do not add a fake series or an
extension for this.
```json
"config": {
"events": [
{ "date": "2026-10-14", "time": "00:01", "timeZone": "America/Los_Angeles", "label": "Product Hunt live" },
{ "date": "2026-10-14", "time": "12:00", "timeZone": "America/New_York", "label": "Launch posts on X, LinkedIn" }
]
}
```
- Each event is `{ date: "YYYY-MM-DD", label, time?: "HH:MM", timeZone?, color? }`;
multiple are allowed. `date` is the calendar date in the event's own zone.
Give `time` when the moment matters (a launch hour); `timeZone` is an IANA
zone and needs a `time`. Without a zone, the time is UTC.
- A badge sits on the x-axis at the date with a faint dashed guide line.
Hovering the badge lists every event on that day with its time, shown in the
event's own zone ("12:01 AM PT"). It adds no legend entry and does not change
the series tooltip.
- Events on the same day share one badge; on an hourly axis each timed event
sits in its own hour, and an event without a time sits on the first hour
shown for its date.
- On a daily or coarser axis, a date missing from the data snaps to the nearest
x value. On an hourly axis, an event without a time whose date has no hours
shown is hidden. A date outside the visible range is hidden; a non-date
x-axis shows nothing.
- `mutate-dashboard` rejects malformed events and other chart types.
- Scheduled email reports draw the guide line, badge, and label text, including
on combo charts. Labels sit in rows above the plot: same-day labels join, a
long one wraps at words, and days that find no room are counted, not cut.
## 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`.
GitHub에서 보기