- name
- maple-dashboard-widgets
- description
- Build, repair, or review Maple dashboard widgets via the MCP. Triggers on phrases like 'create_dashboard', 'add_dashboard_widget', 'update_dashboard_widget', 'dashboard widget JSON', 'panel_type', 'QueryDraft', or any session that submits widget JSON to the maple MCP. Covers the panel-type table, the kind-discriminated data source, the percent vs percent_100 unit rule, valid aggregations and group-by tokens per source, the custom whereClause grammar, the scalar reduceToValue transform, and the verification step (MCP success != chart correctness).
# Maple dashboard widgets via MCP
Everything below is generated from the live widget schema by
`bun run --cwd apps/ai mcp:docs`. **Do not edit this file by hand.** Edit
`apps/ai/src/mcp/lib/dashboard-schema-doc.ts` and regenerate. The same module backs the
`describe_dashboard_schema` MCP tool, so an agent at runtime and a reader here see one truth.
## When to use this skill
When constructing widget JSON for `mcp__maple__create_dashboard`,
`mcp__maple__add_dashboard_widget`, `mcp__maple__update_dashboard_widget` or
`mcp__maple__replace_dashboard_widgets`.
For a brand-new dashboard, prefer the simplified `widgets` array on `create_dashboard`
(`{ title, source, metric, group_by?, service_name?, unit? }`). It fills in the traps below.
Reach for raw JSON when the simplified spec can't express what you need: multi-query charts,
formulas, hidden series, non-default transforms.
## The three silent failures
1. **A data source is a `kind`-discriminated union.** `{ "endpoint": …, "params": … }` is the
retired v2 shape and will not decode.
2. **`percent` means a 0–1 fraction; `percent_100` means 0–100.** Inverted from Grafana.
3. **`groupBy` is ignored unless `addOns.groupBy` is `true`.** No error, just an ungrouped total.
## Verification
MCP success is not chart correctness. The mutation tools reject queries the engine can't honor and
widgets that cannot render, and return an automatic `inspect_chart_data` summary for everything
else. Read the verdict: `suspicious` or `broken` means fix and resubmit.
## Panel types
`panel_type` is the whole answer to “what kind of widget is this”. Pass it and the
persisted `visualization`, the `display.chartId` and the raw-SQL display type are all
derived for you. The legacy `visualization` parameter is still accepted, but it collapses
line/bar/area into `chart` and then needs a `display.chartId` to tell them apart. The two
columns below are what `panel_type` resolves to, and are what you write directly when
authoring an assembled widget rather than calling `add_dashboard_widget`. A panel whose
`chartId` is `-` takes none: the `visualization` alone identifies it.
| panel_type | Label | `visualization` | `display.chartId` | Raw-SQL type | Default w×h | Requirements |
|---|---|---|---|---|---|---|
| `line` | Line | `chart` | `query-builder-line` | `line` | 4×6 | - |
| `bar` | Bar | `chart` | `query-builder-bar` | `bar` | 4×6 | - |
| `hbar` | Horizontal Bar | `hbar` | `query-builder-hbar` | `hbar` | 4×6 | needs a group-by |
| `area` | Area | `chart` | `query-builder-area` | `area` | 4×6 | - |
| `pie` | Pie | `pie` | `query-builder-pie` | `pie` | 4×6 | needs a group-by |
| `stat` | Stat | `stat` | - | `stat` | 3×4 | needs `transform.reduceToValue` |
| `gauge` | Gauge | `gauge` | - | `stat` | 4×6 | needs `transform.reduceToValue` |
| `table` | Table | `table` | - | `table` | 6×5 | - |
| `list` | List | `list` | - | - | 6×5 | **no raw-SQL support** |
| `histogram` | Histogram | `histogram` | `query-builder-histogram` | `histogram` | 4×6 | - |
| `heatmap` | Heatmap | `heatmap` | `query-builder-heatmap` | `heatmap` | 4×6 | needs a group-by |
| `funnel` | Funnel | `funnel` | `query-builder-funnel` | `funnel` | 4×6 | needs a group-by |
| `paths` | Paths | `paths` | `query-builder-paths` | - | 6×5 | **no raw-SQL support** |
| `markdown` | Note | `markdown` | - | - | 4×5 | **no raw-SQL support** |
### Choosing one
- **line / area / bar**: a value over time. `area` and `bar` accept `display.stacked`; `line` does not.
- **hbar**: a ranked “top N by volume”. Each row is labelled with its share of the **total**.
- **funnel**: sequential stages with a drop-off. Labels each bar as a share of the *largest*,
so an unranked breakdown of four equal things reads “100%” four times. Use `hbar` for that.
`display.funnel.variant: "dropoff"` draws the same funnel as one column per step with the
loss between steps, the median time between them, and where the leavers went.
- **paths**: what people do in the steps after (or before) one event or page, as a flow.
Defined by `display.paths` alone; no query set.
- **pie**: composition, few slices. Collapses a long tail into “Other”.
- **stat / gauge**: one number. A gauge adds an arc; set `display.gauge.min`/`max` to match the unit.
- **table**: rows and columns; set `display.columns` for headers and per-column units.
- **list**: recent traces/logs. Configured by `display.listDataSource`, never by SQL.
- **heatmap / histogram**: a distribution. A histogram over traces can bucket raw values client-side.
- **markdown**: a static note. Takes no query at all.
## Data sources
A widget's `dataSource` is a discriminated union over `kind`: `query`, `raw_sql`, `route`, `static`. Every arm requires its `kind`.
> **If you have seen `{ "endpoint": …, "params": … }` anywhere, that is the retired v2 shape
> and it will not decode.** A `query` source spreads `queries`/`formulas` at the TOP LEVEL,
> not under `params`, and requires `resultShape`.
### `kind: "query"`: the query builder
`resultShape` is required and is one of `timeseries` (a value over time), `breakdown`
(one row per group) or `list` (raw rows). Optional: `formulas`, `comparison`, `limit`,
`defaultLimit`, `columns`, `transform`.
```json
{
"kind": "query",
"resultShape": "timeseries",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "service.name = \"api\"",
"aggregation": "error_rate",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [
"service.name"
],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
]
}
```
### `kind: "raw_sql"`: your own ClickHouse SQL
```json
{
"kind": "raw_sql",
"sql": "SELECT count() AS value FROM logs WHERE $__orgFilter",
"displayType": "stat"
}
```
### `kind: "static"`: a markdown note, no request
```json
{
"kind": "static"
}
```
### `kind: "route"`: a curated built-in panel
`{ "kind": "route", "endpoint": "service_overview", "params": { … } }`. These back the
prebuilt panels; you rarely author one by hand.
### Scalar panels need a reduction
A `stat` or `gauge` reads `data[0].value`. Without `transform.reduceToValue` it renders
`[object Object]`. `add_dashboard_widget` injects `{ field: "value", aggregate: "first" }`
when you omit it; set it explicitly to choose a different reducer. Valid aggregates:
`sum`, `first`, `count`, `avg`, `max`, `min`: **there is no `last`**.
Which one depends on what the query returns, because the query is bucketed over time and
the reducer collapses those buckets into one number:
- **A rate or a count** (`count`, a metrics `rate`) → `sum`, for a window total.
- **A latency percentile or an average** (`p95_duration`, `avg_duration`, a gauge metric)
→ `avg` for the typical value over the window, or `max` for the worst bucket. **Not**
`sum`: adding percentiles together is meaningless, and it is the common wrong choice.
- **A current reading**, where only the newest bucket matters → `first`.
```json
{
"kind": "query",
"resultShape": "timeseries",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "",
"aggregation": "count",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": false,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
],
"transform": {
"reduceToValue": {
"field": "value",
"aggregate": "sum"
}
}
}
```
### The breakdown shape
`resultShape: "breakdown"` returns one row per group instead of a series over time, the
shape `pie`, `hbar`, `funnel` and `heatmap` need. It requires a group-by, and `limit` caps
the rows (honoured for 1–100).
```json
{
"kind": "query",
"resultShape": "breakdown",
"queries": [
{
"id": "q1",
"name": "A",
"enabled": true,
"whereClause": "",
"aggregation": "count",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": [
"service.name"
],
"having": "",
"orderBy": "",
"limit": "",
"legend": "",
"dataSource": "traces"
}
],
"limit": 10
}
```
### Product-event funnels (`panel_type: "funnel"` + `display.funnel.steps`)
A funnel widget has two modes. Without `display.funnel.steps` it draws a group-by breakdown
as descending stages (the shape above). With them it is a **conversion funnel over product
events** (page views, `track()` events and server-side events, stitched per person), and
the query set is not used at all. Set the definition on `display_json.funnel` and
`add_dashboard_widget` derives the data source (`kind: "route"`,
`endpoint: "product_events_funnel"`) for you; do not pass `data_source_json`.
- `steps`: 1–10, in order. `{ kind: "event", eventName, attributeEquals? }`,
`{ kind: "page", pagePath, host? }`, or (**step 1 only**)
`{ kind: "session", dimension, value }` with `dimension` one of `referrerHost`,
`utmSource`, `utmMedium`, `utmCampaign`, `country`, `host`.
- `keyBy`: `person` (default; user id, else the visitor's linked user, else the visitor),
`visitor`, `user`, or `session`.
- `windowSeconds`: the whole chain must complete within this many seconds of step 1
(default 86400). Must be positive.
- `breakdownBy`: split the funnel by a session dimension (`referrerHost`, `utmSource`,
`utmMedium`, `utmCampaign`, `country`, `host`) or `attribute:<key>`; the widget draws one
bar per group per step for the top 6 groups by step-1 count.
- `filters`: the population filter: only persons with a session matching these
dimensions take part. `{ host?, pagePath?, referrerHost?, country?, deviceType?,
browserName?, osName?, language?, utmSource?, utmMedium?, utmCampaign?,
visitorType?: "new" | "returning" }`. They are spread FLAT into the derived route params.
The step count, the step-1-only session rule and a positive `windowSeconds` are enforced on
write: a definition that breaks one of them is rejected rather than saved, because the
query engine would reject it again on every render.
Use `list_product_events` to see which event names exist, and `query_funnel` to try a
definition before pinning it to a board.
```json
{
"id": "w-signup-funnel",
"visualization": "funnel",
"dataSource": {
"kind": "route",
"endpoint": "product_events_funnel",
View on GitHub