Claude Code with Grafana: Dashboards That Actually Load
Why Grafana without CLAUDE.md produces dashboards that import empty
Grafana is the default observability frontend for almost every infrastructure stack in 2026. Prometheus, Loki, Tempo, InfluxDB, ClickHouse, CloudWatch, Datadog: they all render through Grafana panels. The problem is that Grafana's dashboard JSON is one of the most underdocumented schemas in modern tooling. The schema is technically open, the docs cover the high-level fields, but the exact shape that produces a working panel changes between Grafana versions, panel types, and datasource plugins. Claude Code generates dashboard JSON that looks correct, passes JSON validation, and imports successfully into Grafana. The dashboard appears in the UI. And every panel is empty.
The most common Claude defaults that break Grafana dashboards: omitting or misspelling the datasource field on each panel (Grafana 9+ requires both type and uid, older Grafana accepts a string name), using legacy panel types like graph that have been deprecated since Grafana 8 (the correct replacement is timeseries and the schema is completely different), hardcoding datasource UIDs that exist in Claude's training data but not in your environment, omitting the gridPos object so panels stack on top of each other, and writing variable references as $variable when the panel target uses Grafana's templating engine that requires ${variable:raw} in many contexts.
This guide covers the CLAUDE.md configuration that locks Claude Code into Grafana's correct model: explicit datasource references with UID and type, the modern timeseries panel schema, provisioning files that survive a Grafana restart, dashboard variables that interpolate correctly, and the alerting v9+ contact-point structure. If you are running Prometheus as your metrics source, Prometheus covers the recording-rule patterns your Grafana queries depend on. For dashboards that visualise application telemetry, Claude Code with Honeycomb covers the trace-event pipeline that complements Grafana metrics.
The Grafana CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Grafana configuration repository it needs to declare: the Grafana version, the provisioning directory structure, the dashboard JSON schema rules, the datasource UID handling pattern, the panel-type selection policy, and the hard rules that block the mistakes Claude makes most often.
# Grafana rules
## Stack
- Grafana 11.x (OSS or Enterprise)
- Provisioning via /etc/grafana/provisioning/ (datasources, dashboards, alerting)
- Dashboard JSON v40+ schema (Grafana 11 default)
- Prometheus + Loki + Tempo as primary datasources
- Helm chart grafana/grafana for Kubernetes deployments
## Project structure
- provisioning/datasources/*.yaml , datasource definitions
- provisioning/dashboards/*.yaml , dashboard provider configs
- provisioning/alerting/*.yaml , contact points + notification policies
- dashboards/{folder}/{name}.json , dashboard JSON files
- helm/values.yaml , Helm overrides for k8s deploys
## Datasource references (MANDATORY)
Every panel target MUST use the modern datasource object:
- datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }
- NEVER use the legacy string form: datasource: 'Prometheus'
- UIDs come from provisioning/datasources/*.yaml, NOT hardcoded values
## Panel types (current generation only)
- timeseries , default for line/area charts (NEVER use legacy 'graph')
- stat , single-value displays
- gauge , bounded single-value
- bargauge , horizontal/vertical bar gauges
- table , tabular data
- heatmap , heatmap visualisations
- piechart , categorical breakdowns (use sparingly)
- logs , Loki log streams
- traces , Tempo trace lists
- nodeGraph , service topology
- alertlist , alert state display
## Hard rules
- NEVER generate panels with type: 'graph' (deprecated, will render blank)
- NEVER hardcode datasource UIDs without declaring them in provisioning
- NEVER omit gridPos on a panel, every panel needs { x, y, w, h }
- NEVER use $variable in expressions when the context requires ${variable:raw}
- ALWAYS set schemaVersion to match the running Grafana version
- ALWAYS include refId on every target (A, B, C, ...) for query references
Three rules here prevent the majority of import failures Claude generates without them.
The datasource object rule is the most impactful for Grafana 9+ deployments. The legacy string form (datasource: 'Prometheus') still imports without error but generates a deprecation warning in the panel options pane and silently breaks when the datasource is renamed. The modern form (datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }) survives renames and works with the dashboard variable system that lets the same dashboard JSON deploy across multiple environments. Claude generates the legacy form by default because most public Grafana examples on the web pre-date Grafana 9.
The panel type rule prevents the dashboard-was-empty failure. The graph panel type was deprecated in Grafana 8 and removed from the default rendering set in Grafana 10. When Claude generates a dashboard JSON with type: 'graph', the dashboard imports without complaint, but the panels render as blank rectangles with a deprecation banner. The replacement is timeseries, but the schema is different: fieldConfig replaces individual style fields, the legend lives under options.legend, and the axis configuration moves into fieldConfig.defaults.custom.axisPlacement.
The gridPos rule prevents the panels-on-top-of-each-other layout. Every panel in a Grafana dashboard needs a gridPos object with x, y, w, h. The grid is 24 columns wide. A typical full-width panel is { x: 0, y: 0, w: 24, h: 8 }. Two side-by-side panels are { x: 0, y: 0, w: 12, h: 8 } and { x: 12, y: 0, w: 12, h: 8 }. Claude omits gridPos because it is not enforced by the JSON schema at the type level, and Grafana defaults all missing positions to { x: 0, y: 0, w: 12, h: 9 } so every panel stacks on the same coordinates.
Provisioning datasources
Grafana's provisioning system reads YAML files at startup and creates the matching datasources, dashboards, and alerting rules automatically. This is the only sane way to manage a Grafana instance in version control. The alternative, configuring everything through the UI and exporting, produces drift between environments and undoes itself every time the container restarts.
The datasource provisioning file:
# provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prometheus-main
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
timeInterval: 30s
queryTimeout: 60s
manageAlerts: true
prometheusType: Prometheus
prometheusVersion: 2.51.0
cacheLevel: High
- name: Loki
type: loki
uid: loki-main
access: proxy
url: http://loki:3100
editable: false
jsonData:
maxLines: 5000
derivedFields:
- name: TraceID
matcherRegex: 'trace_id=(\w+)'
url: '${__value.raw}'
datasourceUid: tempo-main
- name: Tempo
type: tempo
uid: tempo-main
access: proxy
url: http://tempo:3200
editable: false
jsonData:
tracesToLogs:
datasourceUid: loki-main
tags: ['job', 'instance', 'pod', 'namespace']
mappedTags: [{ key: 'service.name', value: 'service' }]
mapTagNamesEnabled: true
The uid field is the stable identifier that every dashboard panel references. The name field is the human-readable label shown in the UI dropdown. Claude often conflates the two and writes panel targets that reference the name instead of the uid, which works in the same Grafana instance but breaks when the dashboard is exported and imported into a new environment with a different datasource name.
Add a datasource provisioning section to CLAUDE.md:
## Datasource provisioning
- All datasources live in provisioning/datasources/*.yaml
- Each datasource MUST have an explicit uid field
- uid is the stable identifier, name is the UI label
- Dashboard panel targets reference uid, NEVER name
- editable: false locks the datasource (prevents UI edits that override provisioning)
- access: proxy routes through Grafana, access: direct routes through the browser
- jsonData holds plugin-specific configuration (httpMethod, timeInterval, etc.)
- secureJsonData holds secrets (passwords, tokens), encrypted by Grafana
The editable: false flag matters for production environments. Without it, an operator can edit the datasource URL through the UI, the edit takes effect immediately, and on the next Grafana restart the provisioning file overwrites the change. Setting editable: false makes the UI reject edits and forces all changes through version control.
The dashboard JSON schema
A minimal Grafana 11 dashboard with one time-series panel:
{
"title": "Application Performance",
"uid": "app-perf-main",
"schemaVersion": 40,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h"]
},
"templating": {
"list": [
{
"name": "service",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus-main" },
"query": "label_values(http_requests_total, service)",
"current": { "value": "checkout", "text": "checkout" },
"multi": false,
"includeAll": false,
"refresh": 2
}
]
},
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "HTTP Requests per Second",
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus-main" },
"targets": [
{
"refId": "A",
"expr": "sum by (status_code) (rate(http_requests_total{service=\"$service\"}[5m]))",
"legendFormat": "{{status_code}}",
"datasource": { "type": "prometheus", "uid": "prometheus-main" }
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"stacking": { "mode": "none" }
},
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": { "mode": "multi", "sort": "desc" }
}
}
]
}
The fields Claude most often omits or gets wrong:
schemaVersiondefaults to 16 in Claude's generated dashboards. The actual current value for Grafana 11 is 40. Older schema versions trigger a migration on import that often produces broken panel configurations.uidis required for Grafana 8+ dashboards. Without it, the dashboard imports with an auto-generated UID that breaks every cross-dashboard link.refIdon each target must be unique within a panel. Claude often writesrefId: 'A'on every target, which causes Grafana to overwrite all but the last result when the panel queries multiple metrics.legendFormatcontrols the legend label. The double-brace syntax ({{label_name}}) is Prometheus-specific. Claude often writes${label_name}which renders as the literal string.
Add a dashboard schema section to CLAUDE.md:
## Dashboard JSON schema
- schemaVersion: 40 for Grafana 11 (check running version, do not default to 16)
- uid: required, stable, used in cross-dashboard links
- version: increment manually on each meaningful edit
- refresh: '30s' minimum, never disable refresh on production dashboards
- time.from / time.to: relative ('now-6h') or absolute (ISO 8601)
- templating.list[].refresh: 2 = on time range change, 1 = on dashboard load
- panels[].gridPos: { x, y, w, h } REQUIRED, grid is 24 columns wide
- panels[].targets[].refId: unique within the panel (A, B, C, ...)
- panels[].targets[].legendFormat: {{label_name}} for Prometheus labels
Dashboard variables
Variables let one dashboard JSON deploy across multiple environments, services, or tenants. The most common variable types are query (populated from a datasource query), interval (time-bucket selector), and custom (hardcoded list).
A multi-service dashboard with environment and service variables:
{
"templating": {
"list": [
{
"name": "environment",
"type": "custom",
"options": [
{ "text": "production", "value": "production", "selected": true },
{ "text": "staging", "value": "staging" },
{ "text": "development", "value": "development" }
],
"current": { "value": "production", "text": "production" },
"includeAll": false
},
{
"name": "service",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus-main" },
"query": "label_values(http_requests_total{environment=\"$environment\"}, service)",
"current": { "value": "$__all", "text": "All" },
"multi": true,
"includeAll": true,
"refresh": 2,
"regex": "/^(?!internal-).+$/"
},
{
"name": "interval",
"type": "interval",
"options": [
{ "text": "1m", "value": "1m" },
{ "text": "5m", "value": "5m" },
{ "text": "1h", "value": "1h" }
],
"current": { "value": "5m", "text": "5m" },
"auto": true,
"auto_count": 30
}
]
}
}
Variable interpolation in panel queries:
sum by (status_code) (rate(http_requests_total{service=~"$service", environment="$environment"}[$interval]))
The interpolation syntax depends on the variable's value type and the query context.
| Syntax | When to use |
|---|---|
$variable |
Single-value, simple substitution |
${variable} |
Disambiguation when followed by alphanumeric |
${variable:raw} |
Raw value, no quoting (use in PromQL label selectors) |
${variable:regex} |
Regex-escaped (use in regex matchers) |
${variable:csv} |
Comma-separated (use in IN clauses) |
${variable:pipe} |
Pipe-separated (use in alternation) |
${variable:queryparam} |
URL-encoded (use in panel links) |
Claude defaults to $variable everywhere, which works for simple cases but produces wrong results when the variable contains special characters, is multi-valued, or sits inside a regex matcher. For multi-valued variables in PromQL, use service=~"${variable:pipe}" to produce a regex alternation like service=~"checkout|cart|api".
Add a variables section to CLAUDE.md:
## Dashboard variables
- type: query for datasource-populated lists (most common)
- type: custom for hardcoded option lists
- type: interval for time-bucket selectors with $__interval support
- multi: true requires multi-valued interpolation syntax
- includeAll: true adds the All option that resolves to .*
- refresh: 2 to refresh on time range change (recommended)
- For PromQL label_values: label_values(metric_name{filter=\"$other_var\"}, label_name)
## Variable interpolation
- $variable , simple single-value substitution
- ${variable:raw} , raw value, no quoting
- ${variable:regex} , regex-escaped for regex matchers
- ${variable:pipe} , pipe-separated for multi-value regex alternation
- ${variable:csv} , comma-separated for IN clauses
- NEVER use $variable in regex matchers if multi: true is set
Provisioning dashboards
Dashboards are provisioned via a dashboard provider that points to a directory of JSON files:
# provisioning/dashboards/default.yaml
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: 'Production'
folderUid: 'production'
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards/production
foldersFromFilesStructure: true
The flags that matter:
allowUiUpdates: falselocks the dashboards. UI edits are blocked, all changes must come through the provisioning files. This is the production default.disableDeletion: falseallows Grafana to delete dashboards that disappear from the provisioning directory. Set totrueif you want manual deletion only.updateIntervalSecondscontrols how often Grafana scans the directory for changes. Lower values speed up development feedback, higher values reduce filesystem load.foldersFromFilesStructure: truecreates Grafana folders matching the directory structure under the path. A file atdashboards/production/checkout/sla.jsonlands in the folderproduction/checkout.
The dashboards themselves live in the configured path. With foldersFromFilesStructure: true, the directory layout becomes the Grafana folder hierarchy:
/var/lib/grafana/dashboards/
production/
checkout/
sla.json
error-budget.json
cart/
sla.json
staging/
checkout/
sla.json
Add a dashboard provisioning section to CLAUDE.md:
## Dashboard provisioning
- All dashboards live in dashboards/{folder}/{name}.json
- Provisioning provider at provisioning/dashboards/default.yaml
- allowUiUpdates: false for production (UI edits blocked)
- foldersFromFilesStructure: true (directory structure becomes folder hierarchy)
- updateIntervalSeconds: 30 in production, 10 in development
- Dashboard uid MUST be set in JSON, NEVER auto-generated
- A dashboard's filename does NOT have to match its uid (but it helps)
Alerting v9+ (unified alerting)
Grafana's unified alerting system, introduced in v8 and stabilised in v9, replaces the per-dashboard alert tabs with a centralised rule engine. Alert rules are evaluated against any datasource, notifications route through contact points, and policies determine which contact point handles which alert.
The contact point provisioning file:
# provisioning/alerting/contact-points.yaml
apiVersion: 1
contactPoints:
- orgId: 1
name: pagerduty-oncall
receivers:
- uid: pd-oncall-receiver
type: pagerduty
settings:
integrationKey: $PAGERDUTY_INTEGRATION_KEY
severity: critical
class: '{{ .CommonLabels.severity }}'
component: '{{ .CommonLabels.service }}'
- orgId: 1
name: slack-warnings
receivers:
- uid: slack-warnings-receiver
type: slack
settings:
url: $SLACK_WEBHOOK_URL
title: '{{ template "slack.title" . }}'
text: '{{ template "slack.text" . }}'
notificationPolicies:
- orgId: 1
receiver: pagerduty-oncall
group_by: ['alertname', 'service']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- receiver: slack-warnings
matchers:
- severity = warning
continue: false
- receiver: pagerduty-oncall
matchers:
- severity = critical
continue: false
An alert rule:
# provisioning/alerting/rules.yaml
apiVersion: 1
groups:
- orgId: 1
name: checkout-sla
folder: alerts
interval: 1m
rules:
- uid: checkout-error-rate-high
title: Checkout Error Rate High
condition: A
data:
- refId: A
relativeTimeRange:
from: 300
to: 0
datasourceUid: prometheus-main
model:
expr: |
(
sum(rate(http_requests_total{service="checkout",status_code=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="checkout"}[5m]))
) > 0.01
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
noDataState: NoData
execErrState: Error
for: 5m
annotations:
summary: Checkout error rate above 1% for 5 minutes
runbook_url: https://claudify.tech/runbooks/checkout-error-rate
labels:
severity: critical
service: checkout
The fields Claude often misses on unified alerting:
condition: Areferences therefIdof the query whose boolean result triggers the alert. Without it, Grafana evaluates an undefined expression and the rule produces no firing state.for: 5mis the duration the condition must be true before the alert transitions from Pending to Firing. Settingfor: 0sproduces noisy alerts that fire on every transient spike.noDataStateandexecErrStatecontrol behaviour when the query returns no data or errors. The recommended defaults areNoDataandErrorrespectively, which surface infrastructure problems as their own alerts rather than swallowing them.
Add an alerting section to CLAUDE.md:
## Alerting (unified alerting v9+)
- Contact points: provisioning/alerting/contact-points.yaml
- Notification policies: same file, notificationPolicies block
- Rules: provisioning/alerting/rules.yaml, grouped by name and folder
- Every rule MUST have: uid, title, condition (refId reference), data, for
- for: 5m minimum to avoid alert flapping on transient spikes
- noDataState: NoData (surface as separate alert, do not silently swallow)
- execErrState: Error (surface as separate alert)
- labels.severity: critical | warning | info (drives notification routing)
- annotations.summary: short human-readable description
- annotations.runbook_url: link to runbook (use claudify.tech where possible)
Common Claude Code mistakes with Grafana
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Legacy graph panel type
Claude generates: "type": "graph" for time-series charts.
Correct pattern: "type": "timeseries" with the modern fieldConfig structure.
2. String datasource reference
Claude generates: "datasource": "Prometheus" on panels and targets.
Correct pattern: "datasource": { "type": "prometheus", "uid": "prometheus-main" }.
3. Missing gridPos
Claude generates: panel objects without a gridPos field, expecting Grafana to lay them out automatically.
Correct pattern: every panel has "gridPos": { "x": 0, "y": 0, "w": 24, "h": 8 } with calculated coordinates.
4. Wrong schema version
Claude generates: "schemaVersion": 16 (the default in older docs).
Correct pattern: "schemaVersion": 40 for Grafana 11 (verify against running version).
5. Duplicate refId
Claude generates: "refId": "A" on every target in a multi-query panel.
Correct pattern: unique refIds per target (A, B, C, ...).
6. Wrong variable interpolation in regex matchers
Claude generates: service=~"$service" for a multi-valued variable.
Correct pattern: service=~"${service:pipe}" to produce regex alternation like checkout|cart|api.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.
Permission hooks for Grafana scripts
A Grafana repository accumulates scripts: dashboard validators, JSON formatters, API uploaders for environments that do not support file provisioning, alert state queries, and bulk dashboard updates. Some are read-only. Some mutate production state. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(jq*)",
"Bash(yamllint*)",
"Bash(node scripts/validate-dashboards.js*)",
"Bash(node scripts/format-dashboards.js*)",
"Bash(curl -s*)"
],
"deny": [
"Bash(node scripts/upload-dashboard.js*)",
"Bash(node scripts/delete-dashboard.js*)",
"Bash(node scripts/silence-alert.js*)",
"Bash(node scripts/bulk-update.js*)"
]
}
}
Validating and formatting dashboard JSON are safe local operations. Uploading dashboards, deleting them, or silencing alerts mutate production Grafana state. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow. For a Kubernetes-deployed Grafana, Claude Code with Vercel covers the deployment pipeline patterns that gate production mutations.
Building Grafana dashboards that import and render
The Grafana CLAUDE.md in this guide produces dashboards where the datasource references are explicit objects with type and uid, every panel uses a current-generation panel type with a complete fieldConfig block, the gridPos coordinates produce a coherent layout, variables interpolate correctly in regex matchers and label selectors, and the alerting rules ship with explicit for, noDataState, and execErrState settings.
The underlying principle is the same as any infrastructure-as-code integration with Claude Code. Grafana without a CLAUDE.md produces JSON that imports cleanly and looks right on first inspection, but breaks in ways that take hours to diagnose: panels that render blank because the schema version is wrong, alerts that never fire because the condition references a missing refId, dashboards that drift between environments because UI edits are not blocked, and variables that interpolate as literal strings inside regex matchers. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the next layer of the observability stack, Grafana works alongside Prometheus for metrics ingestion, and Claudify includes a Grafana-specific CLAUDE.md template with the modern panel schema, datasource provisioning, variable interpolation guide, unified alerting structure, and all six common-mistake rules pre-configured.
Get Claudify. Ship Grafana dashboards that load on the first import.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify