# Maple Dashboard Widgets

> 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).

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

---



# 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/api/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 |
| `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.
- **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
}
```

### A complete widget

The sections above describe `add_dashboard_widget`'s parameters, which it assembles into a
widget for you. `update_dashboard_widget`, `replace_dashboard_widgets` and `dashboard_json`
take the assembled object instead — this is its shape. `timeRange` and `sectionId`/`tabId`
are the only other top-level keys, both optional.

```json
{
  "id": "w-error-rate",
  "visualization": "chart",
  "dataSource": {
    "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"
      }
    ]
  },
  "display": {
    "title": "Error rate by service",
    "chartId": "query-builder-line",
    "unit": "percent",
    "chartPresentation": {
      "legend": "visible"
    }
  },
  "layout": {
    "x": 0,
    "y": 0,
    "w": 4,
    "h": 6,
    "minW": 2,
    "minH": 2
  }
}
```

## Units (`display.unit`)

**The one that bites: Maple's percent tokens are inverted relative to Grafana's.**

- `percent` expects a **fraction 0–1** and multiplies by 100 on render. (Grafana calls this `percentunit`.)
- `percent_100` expects **0–100** and renders as-is. (Grafana calls *this* one `percent`.)

The traces `error_rate` aggregation returns a 0–1 ratio, so it pairs with `percent`.
Most exporter metrics named `*_percent`/`*_utilization` already report 0–100 and want
`percent_100`. Getting it backwards renders 100× off with no error anywhere.

| Token | Label | Expects |
|---|---|---|
| `none` | None | any number; rendered like `number` |
| `number` | Number | any number; grouped thousands |
| `percent` | Percent (0–1) | a FRACTION 0–1; multiplied by 100 on render. `error_rate` is this one |
| `percent_100` | Percent (0–100) | already 0–100; rendered as-is. Grafana spells this one `percent` |
| `duration_ms` | Duration (ms) | milliseconds. The query builder's `*_duration` aggregations are already ms |
| `duration_s` | Duration (s) | seconds |
| `duration_us` | Duration (µs) | microseconds |
| `duration_ns` | Duration (ns) | nanoseconds |
| `bytes` | Bytes | bytes; scaled decimal (1000-base), not 1024 |
| `requests_per_sec` | Requests/sec | a per-second rate |
| `short` | Short | any number; rendered like `number` |

`display.unit` is stored as an open string, so an unrecognised value like `"ms"`, `"%"`
or `"GB"` **saves successfully and then renders as a plain number**. The write tools warn
when they see one and suggest the right token. The same vocabulary applies to
`display.yAxis.unit`, `display.xAxis.unit` and `display.columns[].unit`.

A gauge's arc is independent of its unit and defaults to 0–100: on a `percent` gauge set
`display.gauge: { "min": 0, "max": 1 }` or the needle sits pinned at zero.

## Queries

A query draft is discriminated on `dataSource` (`traces` / `logs` / `metrics`). The
metric-only fields belong solely to `metrics` queries; do not add them to trace or log
queries:

- `metricName` — required; discover real names with `list_metrics`.
- `metricType` — required, one of `sum`, `gauge`, `histogram`, `exponential_histogram`. Anything else fails to decode.
- `signalSource` — optional, one of `default`, `meter`. Omit it unless you know you need `meter`.
- `isMonotonic` — optional; `false` marks a Sum as an UpDownCounter, which changes the
  aggregations that make sense (`rate`/`increase` assume a monotonic counter).

### `addOns` is required, and all five keys must be present

`addOns: { groupBy, having, orderBy, limit, legend }` — every key, every time. A missing
one fails to decode. Each flag gates whether the matching field is read at all, which is
why `groupBy` without `addOns.groupBy: true` silently does nothing.

### Aggregations, per source

| dataSource | Valid `aggregation` |
|---|---|
| `traces` | `count`, `avg_duration`, `p50_duration`, `p95_duration`, `p99_duration`, `error_rate` |
| `logs` | `count` |
| `metrics` | `avg`, `sum`, `min`, `max`, `count`, `rate`, `increase` |

On traces only, setting `valueField: "attr.<key>"` switches the query to numeric-attribute
mode, where the aggregation is one of `avg`, `sum`, `min`, `max`, `p50`, `p95`, `p99`.
This is the **only** place a bare `p50`/`p95`/`p99` is valid — latency percentiles are
spelled `p95_duration`. Metrics never accept percentiles.

### Group-by tokens

**`groupBy` is ignored unless `addOns.groupBy` is `true`.** This is the single most common
silent failure: the array is present, the chart shows an ungrouped total, and nothing errors.

| dataSource | Literal tokens | Prefixed |
|---|---|---|
| `traces` | `service`, `service.name`, `service_name`, `span`, `span.name`, `span_name`, `status`, `status.code`, `status_code`, `http.method`, `none`, `all` | `attr.<key>` |
| `logs` | `service`, `service.name`, `service_name`, `severity`, `severity_text`, `none`, `all` | **none** |
| `metrics` | `service`, `service.name`, `none`, `all` | `attr.<key>`, `resource.<key>` |

Anything outside the literal list must use a supported prefix; unrecognised tokens are
dropped, which makes the write tools reject the widget rather than save a mis-scoped chart.

### `whereClause` is a custom grammar, not SQL

Operators — the only ones: `=`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `!contains`,
`exists`, `!exists`. Clauses join with ` AND `; there is no `OR` and no parentheses.
Values use double quotes. **There is no `IS NULL` / `IS NOT NULL`** — write `<key> exists`
or `<key> !exists`. `exists` means present *and* non-empty, because attributes live in
ClickHouse `Map` columns where a missing key reads back as `''`.

On `traces` any bare key outside the structured allowlist (`service.name`, `span.name`,
`deployment.environment`, `vcs.ref.head.revision`, `root_only`, `has_error`) is treated as
a span attribute, so `db.system = "clickhouse"` works directly. Cap: 5 `attr.*` plus 5
`resource.*` filters per query.

### Formulas and hidden series

`formulas: [{ id, name, expression, legend }]` references queries by `name` (`A / B`), and
is valid on the **timeseries** shape only. Marking a query `hidden: true` is UI-only in raw
JSON — also add `transform.hideSeries.baseNames: ["A"]` or the auxiliary series renders at
full scale and flattens the axis.

## Display config

| Key | Applies to | Notes |
|---|---|---|
| `title`, `description` | all | |
| `unit` | all | See the `units` section — read it before choosing a percent token. |
| `thresholds` | stat, gauge, charts | `[{ value, color, label? }]`; highest matching value wins. |
| `prefix`, `suffix` | stat, gauge | Wrap the formatted value. |
| `chartPresentation.legend` | charts | `visible` \| `hidden` \| `right`. |
| `chartPresentation.seriesStats` | charts | Min/Max/Mean/Last table; costs up to 45% of tile height. |
| `chartPresentation.tooltip` | charts | `visible` \| `hidden`. |
| `chartPresentation.showPoints` | charts | Omit for auto, `true` always, `false` never. |
| `stacked` | area, bar | Meaningless on `line`. |
| `curveType` | line, area | `linear` \| `monotone`. |
| `yAxis.logScale`, `softMin`, `softMax`, `fitYAxisToData` | charts | |
| `columns` | table, list | `[{ field, header, unit?, width?, align?, hidden? }]`. |
| `listDataSource`, `listWhereClause`, `listLimit`, `listRootOnly` | list | |
| `pie` | pie | `{ donut, innerRadius, showLabels, showPercent }`. |
| `gauge` | gauge | `{ min, max }` — defaults to 0–100, which is wrong for a `percent` unit. |
| `histogram` | histogram | `{ bucketCount, bucketWidth, logScaleY }`. |
| `heatmap` | heatmap | `{ colorScale, scaleType }`. |
| `funnel` | funnel | `{ showStepPercent }`. |
| `markdown` | markdown | `{ content }` — the note body. |
| `sparkline` | stat | `{ enabled, dataSource? }`; embeds a full nested data source. |

### Stored but not rendered

These decode and persist, and the chart renderer **ignores them**. Setting one to fix a
problem will look like it worked and change nothing:
`yAxis.min`, `yAxis.max`, every `xAxis` field, `seriesMapping`, `colorOverrides`,
`chartPresentation.fillNulls`, `gauge.style`.
To bound a chart's axis use `yAxis.softMin`/`softMax`.

### Per-widget time range

A widget follows the dashboard's range unless it carries its own top-level `timeRange`:
`{"type":"relative","value":"30m"}` or `{"type":"absolute","startTime":"…","endTime":"…"}`.
Pin one only when the window is part of what the tile means. Because
`update_dashboard_widget` replaces the whole widget, omitting `timeRange` there REMOVES an
existing override.

## Raw SQL widgets

Pass `sql` to `add_dashboard_widget` and the tool builds the data source for you.
**Call `describe_warehouse_tables` first** — a hallucinated table or column silently
produces an empty chart.

### Macros

- `$__orgFilter` → **required**; scopes the query to your org.
- `$__timeFilter(Column)` → a bare column identifier, no expressions. Prefer this in WHERE.
- `$__startTime` / `$__endTime` → `toDateTime(…)` literals for use outside a WHERE comparison.
- `$__interval_s` → bucket size in seconds; only interpolate it if the SQL buckets time.

### Conventions that catch everyone

- Columns are PascalCase (`ServiceName`, `Timestamp`) — never snake_case.
- `StatusCode` / `SeverityText` / `SpanKind` values are **Title Case** (`'Error'`, not `'ERROR'`).
  Wrong casing runs fine and matches zero rows.
- Span `Duration` is **nanoseconds**. Divide by `1e6` for ms.
- `SpanAttributes['key']` — square brackets. A missing key returns `''`, not NULL.
- One statement only; writes are rejected; every query is wrapped in `LIMIT 1001`.

### What to SELECT, per panel type

The renderer is opinionated. Wrong aliases give an empty chart or `[object Object]`.

- **line / area / bar** — a DateTime bucket as the FIRST column (alias `bucket`) plus one or
  more **numeric** columns; each becomes a series named after the column. **String columns
  are dropped**, so multi-series must be pivoted in SQL with `countIf(...)` — tall form
  (`bucket, ServiceName, count()`) collapses to one aggregate line.
- **stat / gauge** — one scalar aliased `value`.
- **pie / funnel / hbar** — a string column aliased `name` plus a numeric column. Cap at ~8–10 rows.
- **heatmap** — three columns aliased `x`, `y`, `value`; string-cast numeric `x`/`y`.
- **histogram** — one numeric column aliased `value`, one row per observation; add `LIMIT 5000`.
- **table** — any rows; columns render in order, so use `AS` for readable headers.
- **list** — not supported. A list is configured by `display.listDataSource`.

```sql
SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket,
       countIf(SeverityText = 'Error') AS Error,
       countIf(SeverityText = 'Warn')  AS Warn
FROM logs
WHERE $__orgFilter AND $__timeFilter(Timestamp)
GROUP BY bucket
ORDER BY bucket
```

`granularity_seconds` only matters if the SQL references `$__interval_s`. Either use
`toStartOfInterval(…, INTERVAL $__interval_s SECOND)` with it, or a fixed `toStartOf*`
without it — mixing them means the setting silently does nothing.

