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', 'QueryDraft', 'trace dashboard widget', 'Invalid input for getQueryBuilderTimeseries', or any session that submits raw widget JSON to the maple MCP. Covers the source-discriminated QueryDraft shape, the custom whereClause grammar, valid aggregations per data source, groupBy prefix conventions, the stat-widget `reduceToValue` transform, hiding auxiliary series on formula charts, and the verification step (MCP success ≠ query success).
72
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
When you are constructing raw widget JSON for any of:
mcp__maple__create_dashboard with a dashboard_json payloadmcp__maple__add_dashboard_widgetmcp__maple__update_dashboard_widgetIf you are creating a fresh dashboard, prefer the simplified widgets array on create_dashboard (SimpleWidgetSpec at apps/api/src/mcp/tools/create-dashboard.ts:94). It side-steps every trap below — fill in title, source, metric, optional group_by, optional service_name, and the tool builds the full shape for you. Raw JSON is for cases the simplified spec can't express (multi-query charts, formulas, hidden series, non-default transforms).
The query draft schema (packages/domain/src/http/query-engine.ts) is a union discriminated on dataSource. The metric-only fields — metricName, metricType, isMonotonic, signalSource — exist only on dataSource: "metrics" queries.
dataSource: "traces" or "logs": do not include metricName, metricType, isMonotonic, or signalSource. They are not part of the trace/log query shape.dataSource: "metrics": include metricName (the metric to query), metricType, and optionally isMonotonic / signalSource.Never add metricName/metricType/isMonotonic/signalSource to a trace or log query — they belong solely to dataSource: "metrics" queries. (Older stored dashboards were migrated to drop them; new widgets must not reintroduce them.)
whereClause is a custom grammar, not SQLParser: packages/domain/src/where-clause.ts:7. Supported operators (the only supported operators):
| Operator | Example |
|---|---|
= != | service.name = "ingest", error.type != "Timeout" |
> < >= <= | http.request.body.size > 1000 |
contains !contains | http.route contains "v1", http.route !contains "/health" |
exists !exists | maple.signal exists, db.system !exists |
Rules:
AND (case-insensitive). No OR, no parentheses.IS NULL / IS NOT NULL. To require an attribute be present, use <key> exists; to require it absent, <key> !exists. This is the single most common mistake.dataSource: "traces" you can filter by any span/resource attribute — query.context = "tracesList", error.type != "Timeout", db.system = "clickhouse". Bare keys outside the small structured allowlist (service.name, span.name, deployment.environment, deployment.commit_sha, root_only, has_error) are auto-treated as span attributes; you can also write them explicitly as attr.<key> / resource.<key>. Cap: 5 attr.* + 5 resource.* filters per query.add_dashboard_widget / update_dashboard_widget / replace_dashboard_widgets run the builder before persisting and FAIL (nothing saved) if a clause can't be honored — e.g. exceeding the attr cap, or an unsupported logs/metrics filter key. (This used to be a silent drop.)Wrong:
service.name = "ingest" AND maple.signal IS NOT NULLRight:
service.name = "ingest" AND maple.signal existsUse as a template. Fill whereClause, groupBy, aggregation, display.title, display.unit, layout:
{
"id": "w0",
"visualization": "chart",
"dataSource": {
"endpoint": "custom_query_builder_timeseries",
"params": {
"queries": [
{
"id": "q-w0",
"name": "A",
"enabled": true,
"hidden": false,
"dataSource": "traces",
"whereClause": "service.name = \"ingest\" AND maple.signal exists",
"aggregation": "count",
"stepInterval": "",
"orderByDirection": "desc",
"addOns": {
"groupBy": true,
"having": false,
"orderBy": false,
"limit": false,
"legend": false
},
"groupBy": ["maple.signal"],
"having": "",
"orderBy": "",
"limit": "",
"legend": ""
}
],
"formulas": [],
"comparison": { "mode": "none", "includePercentChange": true },
"debug": false
}
},
"display": {
"title": "Requests by Signal",
"chartId": "query-builder-bar",
"chartPresentation": { "legend": "visible" },
"stacked": true,
"curveType": "linear",
"unit": "number"
},
"layout": { "x": 0, "y": 0, "w": 6, "h": 4 }
}For visualization: "stat", add dataSource.transform.reduceToValue. Transform schema is at apps/web/src/components/dashboard-builder/types.ts:42:
"transform": {
"reduceToValue": { "field": "value", "aggregate": "sum" }
}Valid aggregate values: "sum" | "first" | "count" | "avg" | "max" | "min". No "last". Without reduceToValue, the series array passes through to the renderer and the stat shows [object Object],....
visualization: "gauge" renders the same scalar as a stat, but on a 180° radial arc. It needs the same reduceToValue transform as a stat widget. Add gauge presentation under display:
"display": {
"unit": "percent",
"gauge": { "min": 0, "max": 100 },
"thresholds": [
{ "value": 70, "color": "var(--chart-3)" },
{ "value": 90, "color": "var(--destructive)" }
]
}display.thresholds color the arc (the highest threshold ≤ the value wins) and place tick marks. gauge.min/max default to 0/100. Arc color falls back to var(--chart-1) when no threshold matches.
display.thresholds also works on chart widgets — each entry draws a dashed horizontal ReferenceLine across line/area/bar charts, with an optional label. Reuse it to mark SLO/alert boundaries.
aggregation values per dataSourceFrom normalizeTraceAggregation / normalizeMetricsAggregation in apps/web/src/components/dashboard-builder/ai/normalize-widget-proposal.ts:148:
count, avg_duration, p50_duration, p95_duration, p99_duration, error_raterate, increase, avg, sum, count, min, max, p50, p95, p99countrate / sum / increase are invalid for traces — a common mistake when porting metrics widgets to traces.
groupBy prefix conventionsThe query-builder groupBy accepts a small literal allowlist per source; everything else
MUST be prefixed attr.<key>. A token that isn't recognized and isn't attr.-prefixed is
dropped — and now causes the widget mutation tools to reject the write rather than silently
breaking the breakdown.
service / service.name, span / span.name, status / status.code, http.method, none / all. Every other attribute needs the attr. prefix — attr.maple.signal, attr.error.type, attr.http.response.status_code, attr.maple.org_id, etc. (Writing error.type bare in groupBy does NOT work — use attr.error.type.)service / service.name, severity, none. Logs do not support attr.* group by.service / none; everything else uses attr. — attr.signal, attr.status, attr.org_id.If a groupBy on an attribute finds zero distinct values, the chart collapses to a single
all series. inspect_chart_data now flags this as EMPTY_GROUPING (verdict broken) instead
of silently showing the ungrouped total.
display.unit is mandatoryAlways set display.unit on chart and stat widgets. The default is "number". Pick more specific where applicable:
duration_ms for latency aggregations (avg_duration, p50_duration, p95_duration, p99_duration)percent for error_ratenumber for countbytes / GB for size aggregationsWhen a chart uses formulas and the auxiliary queries shouldn't render on their own, query.hidden: true is not enough on its own for the raw-JSON path — that flag is only consumed by the UI builder to generate the actual transform. For raw JSON, pair it with dataSource.transform.hideSeries.baseNames:
"transform": {
"hideSeries": { "baseNames": ["A", "B"] }
}baseNames matches each hidden query's legend || name. Otherwise the auxiliary series render at full scale and skew percent-axis charts to absurd values (raw counts showing as "1200%").
The widget mutation tools now do two things automatically that used to be silent gaps:
IS NULL, a 6th attr filter, or an unsupported logs/metrics key surfaces as an error instead of degrading to wrong/empty data.inspect_chart_data summary (verdict + flags). inspect_chart_data now also evaluates formulas[] (so formula/hit-rate widgets verify end-to-end) and applies reduceToValue with the same first-numeric-field fallback the renderer uses (so stat-tile reducedValue reflects what renders). SUSPICIOUS_GAP is informational only — sparse/bursty data no longer downgrades the verdict.Still confirm after submitting:
verdict is suspicious/broken, fix and resubmit.mcp__maple__inspect_chart_data for a deeper look, or mcp__maple__get_dashboard to read back the stored JSON, or load the dashboard URL.Verdict flags worth knowing: EMPTY_GROUPING (groupBy found zero distinct values → one all series), METRIC_NOT_FOUND (a metrics widget references a metric name not in the warehouse — distinct from a real metric with no recent data), BUILDER_WARNINGS (a clause the engine couldn't honor).
If you see Invalid input for getQueryBuilderTimeseries, the culprit is almost always Trap 1 (metric fields on a trace/log query) or a malformed query draft.
To replace a dashboard's whole widget list in one validated, atomic write, use
mcp__maple__replace_dashboard_widgets (widgets_json = a JSON array of widget objects, same
shape as widgets[] from get_dashboard). id and layout are optional per widget
(auto-generated / auto-placed). Every widget is validated before anything persists — if one
widget's query can't be honored, nothing is saved. This is the safe middle ground between N
incremental add_dashboard_widget calls and the corruption-prone full dashboard_json replace.
metricName/metricType/isMonotonic/signalSource; metrics queries include metricName + metricType.whereClause uses only =, !=, >, <, >=, <=, contains, !contains, exists, !exists, joined by AND (no SQL IS NULL).aggregation is valid for the chosen dataSource (no rate/sum on traces).groupBy uses the right prefix: bare only for the per-source allowlist (traces: service/span/status/http.method); every other attribute needs attr.<key> (traces & metrics). Logs: bare service/severity only.display.unit is set (and matches the aggregation — duration_ms, percent, etc.).dataSource.transform.reduceToValue.dataSource.transform.hideSeries.baseNames.suspicious/broken widgets with inspect_chart_data or by loading the dashboard.8d347f8
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.