---
name: metabase
description: Manage Metabase dashboards via API and Playwright — create cards, take screenshots, debug visualizations, and compare dashboards. Use when creating SQL cards, building dashboards programmatically, taking automated screenshots, debugging chart rendering issues, or doing visual QA on Metabase dashboards.
---

# Metabase Dashboard Management

Interact with Metabase programmatically: create dashboards and cards via the REST API, take automated screenshots with Playwright for visual QA, debug visualization issues, and compare dashboards structurally.

## Prerequisites

### Environment variables

Set these in a `.env` file in your project root:

```bash
METABASE_URL=https://your-metabase.example.com   # Base URL, no trailing slash
METABASE_USERNAME=user@example.com                # Login email
METABASE_PASSWORD=your-password                   # Login password
METABASE_API_KEY=mb_abc123...                     # Optional: API key for headless API calls
```

**How to get these:**

| Variable | Where to find it |
|----------|-----------------|
| `METABASE_URL` | Your Metabase instance URL (the one you open in a browser) |
| `METABASE_USERNAME` | Your regular Metabase login email |
| `METABASE_PASSWORD` | Your Metabase login password |
| `METABASE_API_KEY` | Metabase Admin > Settings > Authentication > API Keys > Create Key |

The API key is optional — it provides stateless auth for API calls. Username/password is required for Playwright browser sessions (screenshots) because API keys don't work with browser login.

### Python dependencies

```bash
uv pip install requests python-dotenv
uv pip install playwright   # For screenshots
uv run playwright install chromium
```

---

## Common Operations

### 1. Authenticate with Metabase API

```python
import requests, os
from dotenv import load_dotenv

load_dotenv()
METABASE_URL = os.environ["METABASE_URL"]

# Option A: Session token (expires after ~14 days)
resp = requests.post(f"{METABASE_URL}/api/session", json={
    "username": os.environ["METABASE_USERNAME"],
    "password": os.environ["METABASE_PASSWORD"],
})
session_token = resp.json()["id"]
headers = {"X-Metabase-Session": session_token}

# Option B: API key (if available, simpler)
headers = {"X-Api-Key": os.environ["METABASE_API_KEY"]}
```

### 2. Create a native SQL card (saved question)

```python
card = requests.post(f"{METABASE_URL}/api/card", headers=headers, json={
    "name": "My Card Title",
    "display": "bar",          # bar, line, table, pie, scalar, etc.
    "dataset_query": {
        "type": "native",
        "native": {"query": "SELECT col1, col2 FROM my_table"},
        "database": DATABASE_ID,  # Find via GET /api/database
    },
    "visualization_settings": {
        "graph.dimensions": ["COL1"],
        "graph.metrics": ["COL2"],
    },
    "collection_id": COLLECTION_ID,  # Optional: folder to save in
}).json()
card_id = card["id"]
```

### 3. Create a dashboard with cards

```python
# Create empty dashboard
dash = requests.post(f"{METABASE_URL}/api/dashboard", headers=headers, json={
    "name": "My Dashboard",
    "collection_id": COLLECTION_ID,
}).json()
dashboard_id = dash["id"]

# Add a card to the dashboard
requests.put(f"{METABASE_URL}/api/dashboard/{dashboard_id}", headers=headers, json={
    "dashcards": [{
        "id": -1,  # Negative = new
        "card_id": card_id,
        "row": 0, "col": 0,
        "size_x": 12, "size_y": 8,
    }]
})
```

### 4. Run a card query (bypass cache)

Useful for verifying data without opening the browser:

```python
result = requests.post(
    f"{METABASE_URL}/api/card/{card_id}/query",
    headers=headers,
    json={"ignore_cache": True},
).json()

columns = [c["name"] for c in result["data"]["cols"]]
rows = result["data"]["rows"]
print(f"Columns: {columns}, Rows: {len(rows)}")
for row in rows[:5]:
    print(row)
```

### 5. Update card visualization settings

When a chart shows "Which fields do you want to use for the X and Y axes?", the visualization settings are missing:

```python
requests.put(f"{METABASE_URL}/api/card/{card_id}", headers=headers, json={
    "visualization_settings": {
        # Bar/line charts
        "graph.dimensions": ["X_COLUMN", "SERIES_COLUMN"],
        "graph.metrics": ["Y_COLUMN"],
        "graph.x_axis.title_text": "X Axis Label",
        "graph.y_axis.title_text": "Y Axis Label",
    }
})
```

Common visualization_settings patterns:

| Chart type | Key settings |
|-----------|-------------|
| `bar` | `graph.dimensions`, `graph.metrics` |
| `line` | `graph.dimensions`, `graph.metrics` |
| `pie` | `pie.dimension`, `pie.metric` |
| `table` | `table.columns` (column ordering/visibility) |
| `scalar` | Usually auto-detected |

### 6. Take dashboard screenshots with Playwright

```python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page(viewport={"width": 1920, "height": 1080})

    # Login
    page.goto(f"{METABASE_URL}/auth/login")
    page.fill('input[name="username"]', METABASE_USERNAME)
    page.fill('input[name="password"]', METABASE_PASSWORD)
    page.click('button[type="submit"]')
    page.wait_for_url("**/collection/**", timeout=15000)

    # Navigate to dashboard
    page.goto(f"{METABASE_URL}/dashboard/{dashboard_id}")

    # Wait for cards to load
    page.wait_for_selector('[data-testid="dashcard"]', timeout=60000)
    # Wait for loading spinners to disappear
    page.wait_for_function(
        "document.querySelectorAll('[data-testid=\"loading-indicator\"]').length === 0",
        timeout=60000,
    )

    # Full dashboard screenshot
    page.screenshot(path="dashboard_full.png", full_page=True)

    # Per-card screenshots
    cards = page.query_selector_all('[data-testid="dashcard"]')
    for i, card in enumerate(cards, 1):
        card.screenshot(path=f"card_{i:02d}.png")

    browser.close()
```

### 7. Compare dashboards structurally

```python
def get_dashboard_structure(dashboard_id):
    dash = requests.get(
        f"{METABASE_URL}/api/dashboard/{dashboard_id}",
        headers=headers,
    ).json()
    cards = []
    for dc in dash.get("dashcards", []):
        card = dc.get("card", {})
        cards.append({
            "name": card.get("name"),
            "display": card.get("display"),
            "row": dc.get("row"),
            "col": dc.get("col"),
            "size_x": dc.get("size_x"),
            "size_y": dc.get("size_y"),
        })
    return {
        "card_count": len(cards),
        "cards": sorted(cards, key=lambda c: (c["row"], c["col"])),
        "parameters": [p["name"] for p in dash.get("parameters", [])],
    }

# Compare two dashboards (e.g. old vs new after a migration)
a = get_dashboard_structure(dashboard_a_id)
b = get_dashboard_structure(dashboard_b_id)
assert a["card_count"] == b["card_count"], "Card count mismatch"
assert [c["display"] for c in a["cards"]] == [c["display"] for c in b["cards"]], "Chart types differ"
```

### 8. Read the Claude Code screenshot (visual inspection)

After taking screenshots, use the Read tool on `.png` files. Claude Code is multimodal and can read images directly:

```
Read file: metabase/screenshots/card_07.png
```

This lets you visually inspect chart data, spot anomalies like unexpected distributions, missing series, or broken axes, and compare dashboards before and after changes.

---

## Dashboard Grid & Layout

The Metabase dashboard grid is **24 columns** wide. Common card sizes:

| Card type | Typical size | Notes |
|-----------|-------------|-------|
| Scalar tile | `5x3` or `6x2` | 4-5 scalars fit in one row |
| Bar/line/combo chart | `12x8` (half) or `24x8` (full) | Use full width for trend charts |
| Table | `24x8` | Full width, increase `size_y` for more rows |
| Heading | `24x1` | Section separators |
| Text card | `24x3` to `24x5` | Documentation blocks |
| iframe | `24x15` to `24x20` | Embedded content (Google Sheets, etc.) |

### Virtual cards (no saved question)

Headings, text blocks, links, and iframes are "virtual" cards with `card_id: null`:

```python
# Heading
{"id": -1, "card_id": None, "row": 0, "col": 0, "size_x": 24, "size_y": 1,
 "visualization_settings": {"virtual_card": {"name": None, "display": "heading"}, "text": "Section Title"},
 "parameter_mappings": []}

# Text block (Markdown)
{"id": -2, "card_id": None, "row": 1, "col": 0, "size_x": 24, "size_y": 4,
 "visualization_settings": {"virtual_card": {"name": None, "display": "text"}, "text": "## Markdown content here"},
 "parameter_mappings": []}

# iframe (e.g. Google Sheet)
{"id": -3, "card_id": None, "row": 5, "col": 0, "size_x": 24, "size_y": 20,
 "visualization_settings": {"virtual_card": {"name": None, "display": "iframe"}, "iframe": "https://docs.google.com/spreadsheets/d/SHEET_ID/edit?gid=0&rm=minimal"},
 "parameter_mappings": []}

# Link
{"id": -4, "card_id": None, "row": 25, "col": 0, "size_x": 24, "size_y": 1,
 "visualization_settings": {"virtual_card": {"name": None, "display": "link"}, "link": {"url": "https://example.com", "entity": None}, "text": "Link text"},
 "parameter_mappings": []}
```

**Google Sheets iframe tips:**
- Use `?rm=minimal` to hide menus/toolbars for a cleaner embed
- Use `/edit?gid=0&rm=minimal` for an editable sheet; `/htmlembed?gid=0` for read-only
- The Google Sheets domain must be allowlisted in Metabase Admin > Settings for iframes to work

### Dashboard parameters and filter mappings

Use `type: "dimension"` template tags in native SQL to enable dashboard-level filters. Each tag references a Metabase field ID:

```python
# In the card's SQL: use optional clauses
sql = """SELECT COUNT(*) FROM my_table
WHERE deleted = FALSE
  [[AND {{date_filter}}]]
  [[AND {{owner_filter}}]]"""

# Template tag definition (in dataset_query.native.template-tags)
template_tags = {
    "date_filter": {
        "id": "unique-uuid",
        "name": "date_filter",
        "display-name": "Date",
        "type": "dimension",
        "dimension": ["field", FIELD_ID, None],  # Metabase field ID
        "widget-type": "date/range",             # or "string/=", etc.
        "required": False,
        "default": None,
    }
}

# Dashboard parameter definition
parameters = [{
    "id": "param-uuid",
    "type": "date/range",      # date/range, date/single, string/=
    "name": "Date Range",
    "slug": "date_range",
    "sectionId": "date",
    "default": "past6months",  # or "2026-01-01~2026-03-31"
}]

# Dashcard parameter mapping (connects dashboard param → card template tag)
parameter_mappings = [{
    "parameter_id": "param-uuid",      # matches dashboard parameter id
    "card_id": card_id,
    "target": ["dimension", ["template-tag", "date_filter"], {"stage-number": 0}],
}]
```

**Key pattern:** A single dashboard parameter can map to *different fields* on different cards. For example, the same "Date Range" filter can filter by `deal_created_on` on one card and `deal_won_on` on another — just use different template tag names pointing to different field IDs.

**Find field IDs:** `GET /api/table/{table_id}/query_metadata` returns all fields with their IDs.

### Combo charts (dual-axis bar + line)

Use `display: "combo"` with `series_settings` to mix bar and line series:

```python
{
    "display": "combo",
    "visualization_settings": {
        "graph.dimensions": ["month"],
        "graph.metrics": ["won", "lost", "open", "win_rate_pct"],
        "stackable.stack_type": "stacked",  # "stacked", "normalized" (100%), or None
        "series_settings": {
            "won":  {"display": "bar", "axis": "left", "color": "#84BB4C"},
            "lost": {"display": "bar", "axis": "left", "color": "#ED6E6E"},
            "open": {"display": "bar", "axis": "left", "color": "#509EE3"},
            "win_rate_pct": {"display": "line", "axis": "right", "color": "#F9D45C"},
        },
    }
}
```

The `series_settings` keys must match the exact SQL column aliases. Series with `"display": "line"` escape the stack and render on the secondary axis.

Stacking modes: `"stacked"` = absolute values, `"normalized"` = 100% stacked (percentages).

## Debugging Common Issues

| Problem | Cause | Fix |
|---------|-------|-----|
| "Which fields for X and Y axes?" | Empty `visualization_settings` | `PUT /api/card/{id}` with `graph.dimensions` + `graph.metrics` |
| Card shows stale data | Metabase query cache | Use `{"ignore_cache": true}` in `POST /api/card/{id}/query` |
| Chart shows "(empty)" series | NULL values in grouping column | Fix the data model or add COALESCE |
| Dashboard shows 0 cards | Cards not added to dashboard | Use `PUT /api/dashboard/{id}` with `dashcards` array |
| Playwright login fails | Wrong credentials or 2FA enabled | Check env vars; disable 2FA for service account |
| Screenshot is blank/loading | Cards haven't finished rendering | Increase wait timeout; check for loading indicators |
| **UI "save failed" on dashboard** | Virtual cards or parameter mappings created via API may not round-trip through UI serialization cleanly | Use `PUT /api/dashboard/{id}` via API instead. To debug: open browser DevTools → Network tab → inspect the failed PUT request body for validation errors |
| **Win rate / metrics inconsistent** | Cards filtering on different date fields (created vs won vs lost) | Ensure all related cards use the same base date field (cohort approach). Use `deal_created_on` for all scalar tiles so Total = Won + Lost + Open |
| **iframe save fails in UI** | Known issue with iframe virtual cards in v0.47.x | Add iframe cards via API `PUT /api/dashboard/{id}` — this bypasses the UI validation bug |

## Template Scripts

This skill includes ready-to-use Python scripts in the `scripts/` directory. Copy them into your project and customize the configuration constants at the top.

### `${CLAUDE_SKILL_DIR}/scripts/screenshot_dashboards.py`

Automated screenshot capture with Playwright. Features:
- Full-page and per-card screenshots with timestamps
- Cookie-based authentication (avoids fragile form-based login)
- Structural verification comparing two dashboards (card count, layout, chart types, parameters)
- CLI interface with `--dashboard-id`, `--compare`, and `--no-cards` flags

```bash
# Screenshot specific dashboards
uv run ${CLAUDE_SKILL_DIR}/scripts/screenshot_dashboards.py --dashboard-id 42 15

# Compare two dashboards (screenshots + structural diff)
uv run ${CLAUDE_SKILL_DIR}/scripts/screenshot_dashboards.py --compare 2 42

# Full-page only (skip per-card)
uv run ${CLAUDE_SKILL_DIR}/scripts/screenshot_dashboards.py --dashboard-id 42 --no-cards
```

### `${CLAUDE_SKILL_DIR}/scripts/create_dashboards.py`

Programmatic dashboard creation. Features:
- Reads an existing "source" dashboard and creates a replica pointing at a new base question
- Recursively rewrites `source-table` references in child cards (handles joins and nested queries)
- Preserves layout, chart types, visualization settings, and parameter mappings
- Optionally creates a comparison dashboard from native SQL files

```bash
# Edit the configuration constants at the top, then:
uv run ${CLAUDE_SKILL_DIR}/scripts/create_dashboards.py
```

Both scripts save dashboard IDs to `.dashboard_ids.json` so the screenshot script can automatically pick up dashboards created by the create script.

## Workflow: Visual Dashboard QA

The standard workflow for verifying a dashboard migration or data fix:

1. **Build the models** — `dbt build --target live -s <models>`
2. **Seed if needed** — `dbt seed --target live -s <seed_name>`
3. **Take screenshots** — `uv run ${CLAUDE_SKILL_DIR}/scripts/screenshot_dashboards.py --compare <old_id> <new_id>`
4. **Inspect key cards** — Use Read tool on per-card PNGs
5. **Compare old vs new** — Side-by-side visual comparison + structural verification report
6. **Run comparison queries** — API queries to quantify differences
7. **Fix issues** — Update models, re-build, re-screenshot
8. **Generate report** — Document findings with before/after evidence

---

## Validation

- [ ] `.env` file exists with `METABASE_URL`, `METABASE_USERNAME`, `METABASE_PASSWORD`
- [ ] `playwright install chromium` completed successfully
- [ ] API authentication works (`POST /api/session` returns a session token)
- [ ] Screenshot script produces PNG files in the output directory
- [ ] Per-card screenshots match the expected card count
