← All posts
·15 min read

Claude Code with Grafana: Dashboards That Actually Load

Claude CodeGrafanaObservabilityDashboards
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:

  • schemaVersion defaults 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.
  • uid is required for Grafana 8+ dashboards. Without it, the dashboard imports with an auto-generated UID that breaks every cross-dashboard link.
  • refId on each target must be unique within a panel. Claude often writes refId: 'A' on every target, which causes Grafana to overwrite all but the last result when the panel queries multiple metrics.
  • legendFormat controls 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: false locks the dashboards. UI edits are blocked, all changes must come through the provisioning files. This is the production default.
  • disableDeletion: false allows Grafana to delete dashboards that disappear from the provisioning directory. Set to true if you want manual deletion only.
  • updateIntervalSeconds controls how often Grafana scans the directory for changes. Lower values speed up development feedback, higher values reduce filesystem load.
  • foldersFromFilesStructure: true creates Grafana folders matching the directory structure under the path. A file at dashboards/production/checkout/sla.json lands in the folder production/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: A references the refId of the query whose boolean result triggers the alert. Without it, Grafana evaluates an undefined expression and the rule produces no firing state.
  • for: 5m is the duration the condition must be true before the alert transitions from Pending to Firing. Setting for: 0s produces noisy alerts that fire on every transient spike.
  • noDataState and execErrState control behaviour when the query returns no data or errors. The recommended defaults are NoData and Error respectively, 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
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame