- name
- dashboard-management
- description
- Create, lay out, organize, or share Analytics dashboards and panels: sources, placement, folders, verification, chart markers. Use to create a dashboard, to move, reorder, or lay out panels, or to add a key date or data note; 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, panel resizing, renames, column edits, and adding, editing, showing or hiding key
dates and data notes 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`.
There is no dbt Semantic Layer panel source. For a Semantic Layer metric, answer in chat with `query-dbt-semantic-metric`; build a warehouse panel only on a table the owner names.
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). `height` is optional; when set, it is 1–6 units of 180px, and when omitted the panel sizes to its content. 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.
In Edit mode, drag a panel's right edge to change its grid width and its bottom edge to set a fixed height. Width is saved as grid columns; height is saved in 180px units. Existing panels without `height` keep content-based height until resized.
```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
Key dates mark a launch, release, or product change on `line`, `area`, `bar`,
and `combo` charts with a date x-axis. Do not add a fake series or an extension
for this. They live in two places:
- **Key-date sets** hold moments that matter across charts, such as Launches or
Product changes. A set belongs to the organization (to the user when there is
no organization) and draws on every time chart of every dashboard. Read sets
with `list-key-dates`, passing `dashboardId` for the ones a dashboard draws;
add or edit a date with `save-key-date` (name the set
with `setName` and it is created if missing); rename or create a set with
`save-key-date-set`; remove with `delete-key-date` or `delete-key-date-set`.
Prefer a set to copying one date into many panels.
- **Panel events** in `config.events` hold a date that matters to one chart
only, in the same shape:
```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" }
]
}
```
A dashboard leaves sets off its charts with `config.annotations`, set through
`mutate-dashboard`'s `setDashboard({ annotations: { hiddenKeyDateSets: [setId, ...] } })`.
The list replaces the sets already hidden, so include those that stay hidden;
the hidden data notes are kept. In Edit mode, people do the same from the dashboard
header's Annotations menu, which also adds and edits key dates and sets.
- Each event is `{ date: "YYYY-MM-DD", label, time?: "HH:MM", timeZone?, color? }`;
`color` accepts SVG-safe hex values: `#RGB`, `#RGBA`, `#RRGGBB`, or
`#RRGGBBAA`. Numeric x-axis timestamps use Unix epoch milliseconds.
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.
- A set's date that a panel also lists in `config.events` draws once.
- `mutate-dashboard` rejects malformed events and other chart types.
- Scheduled email reports draw key dates and panel events: 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.
- A dashboard and its report emails draw the key dates of its owner's
organization, minus the sets it hides, even for a viewer or reviewer in
another organization. There `list-key-dates` returns `editable: false`,
without member emails or, unless you can edit the dashboard, the hidden sets,
and no sets at all when no chart there draws key dates: a date you save lands
in your own organization's sets, not on that dashboard. For a Human Review
preview, pass `list-key-dates` and `list-data-notes` the `dashboardId` and the
same `reviewPreview` and `reviewOrgId` as `get-sql-dashboard`.
## Data Notes
A data note says the numbers on a chart are wrong or unusual for a stretch of
time: an incident (`kind: "incident"`) or a tracking problem (`kind: "data"`).
Use one instead of a key date whenever the point is "don't trust this range".
Notes belong to the organization, like key-date sets, and a dashboard draws
its owner organization's. Read them with `list-data-notes`, passing
`dashboardId` for the ones a dashboard draws; add or change one with
`save-data-note` (pass `id` to change only the fields given); remove with
`delete-data-note`.
- `matchEvents` lists the event names the problem affects. A note shows on every
chart whose SQL filters on one of them: either side of `=` (in `WHERE` or
GitHubで見る