Skip to main content

Editing Metabase Cards & Dashboards with Claude Code

This documents the workflow for asking Claude Code (or Cursor) to add or update Metabase questions/cards and dashboards directly, without going through the Metabase UI by hand. It complements the Export/Import Guide — that guide is for bulk promotion of dashboards between environments; this workflow is for targeted edits to a single card's SQL or visualization settings (query fixes, column formatting, UI/UX cleanup).

Why this works

Metabase has no file-based representation of its own — all dashboard/card state lives in Metabase's own metadata database, reachable only through its REST API (GET/PUT /api/card/:id, GET /api/dashboard/:id), authenticated with a Metabase API key.

Important: the JWT-signed embed URLs used by the app (/api/embed/dashboard/..., see apps/frontend-pwa embed params) are read-only viewer tokens — they cannot be used to write. Writes always go through the real Metabase host with a proper API key.

Preferred path: versioned card definitions

If a fix should survive promotion to staging/production — not just live on whichever environment you happened to test it on — it needs a versioned definition, not just a live API edit. This repo has:

  • scripts/metabase/card-definitions/<code>.sql + <code>.json — the card's SQL and visualization settings, checked into git and reviewed like any other SQL change (see scripts/metabase/card-definitions/README.md). Per-environment numeric Metabase IDs are deliberately not stored here — see below.
  • metabase_card / metabase_card_instance database tables — the registry mapping a card's stable code to its numeric Metabase card ID per environment (local / staging / production). Managed through the /system/MetabaseCardInstancePage admin screen in frontend-pwa, the same way metabase_dashboard_instance is managed via /system/MetabaseDashboardInstancePage — registration is manual data entry (you already know the ID from the card's Metabase URL, e.g. .../question/275-...275), not auto-discovered. Use the master-detail admin at /system/MetabaseCardInstancePage to search cards and set per-env IDs. These tables exist purely for this tooling; they are not the same system as metabase_dashboard_instance, which powers the app's live embed-URL runtime for merchants — don't confuse the two.
  • packages/backend/scripts/src/metabase/{pull,push}-metabase-card.ts — the scripts that read/write both of the above.

The loop:

  1. Register the card's per-environment IDs once via the admin UI (or ask a teammate who has access).

  2. tsx src/metabase/pull-metabase-card.ts --env local --card-id <id> --code <code> --name "<name>" --dashboard-id <uuid> (run from packages/backend/scripts) — fetches the current, already-fixed local card and writes the .sql/.json pair.

  3. Edit the .sql/.json pair directly for further changes, or re-run pull after fixing the card in Metabase's UI locally.

  4. tsx src/metabase/push-metabase-card.ts --env local --definition <code> --dry-run — the diff against local's live card should be empty; this is the round-trip proof before touching any other environment.

  5. Real push (local first, then staging, then production): each real push requires --verify-business-id — a successful PUT only means Metabase accepted the SQL, not that it's correct, so every real push executes the query afterwards and checks its result columns. Always use the pre-verified FIXX business from .env — do not invent or copy-paste a UUID from a browser URL:

    # Local changes (default / safest)
    tsx src/metabase/push-metabase-card.ts --env local --definition <code> \
    --verify-business-id "$METABASE_TEST_BUSINESS_ID_LOCAL"

    # Then staging / production (same pattern)
    tsx src/metabase/push-metabase-card.ts --env staging --definition <code> \
    --verify-business-id "$METABASE_TEST_BUSINESS_ID_STAGING"
    tsx src/metabase/push-metabase-card.ts --env production --definition <code> \
    --verify-business-id "$METABASE_TEST_BUSINESS_ID_PRODUCTION"

Full details and file formats are in scripts/metabase/card-definitions/README.md. Staging/production also need API keys + DB tunnel vars (METABASE_API_KEY_AGENT_{STAGING,PRODUCTION}, STAGING_DATABASE_URL/PRODUCTION_DATABASE_URL) — check whether they are already in your local .env; if not, see "One-time setup: staging/production access" below.

One-time setup: staging/production access

Local Metabase work only needs METABASE_API_KEY_AGENT_DEV and METABASE_TEST_BUSINESS_ID_LOCAL (already in a typical synced .env). Staging/production additionally need API keys, DB tunnel URLs, and the matching METABASE_TEST_BUSINESS_ID_* — check .env first; if those vars are missing, set them up once per session:

The local .env var name is irregular — don't guess it. resolveMetabaseApiKey in packages/backend/scripts/src/metabase/metabase-api.ts maps environment to var name as env === "local" ? "DEV" : env.toUpperCase(), so the three .env entries are:

Environment.env var name
localMETABASE_API_KEY_AGENT_DEV (not _LOCAL)
stagingMETABASE_API_KEY_AGENT_STAGING
productionMETABASE_API_KEY_AGENT_PRODUCTION

A METABASE_API_KEY_AGENT_LOCAL entry looks correct but is silently never read — this has actually happened once already in this repo's history.

1. Metabase API keys — create manually, same steps as the local key: staging/production Metabase → Settings → Admin settings → API Keys → Create API Key. There is no existing key to reuse. Once created, save it into Doppler for next time under the stg/prd config — as of this writing the convention actually used is the secret name METABASE_API_KEY_AGENT_DEV within the stg/prd config scope (i.e. reusing the same secret name as local, just in a different config namespace, not a _STAGING/_PRODUCTION suffix). Whichever name you use in Doppler, the local .env must have the correctly-suffixed var name the scripts actually read (METABASE_API_KEY_AGENT_STAGING / METABASE_API_KEY_AGENT_PRODUCTION, per resolveMetabaseApiKey in packages/backend/scripts/src/metabase/metabase-api.ts) — download-doppler-secrets.sh won't rename the var for you, so copy the value into .env under the right name by hand.

2. Database access (metabase_card/metabase_card_instance registry lookups) — both staging and production's app database live on the same Cloud SQL instance as Metabase's own metadata DB (a historical naming artifact, not a coincidence to be suspicious of):

  • staging: barto-dev:us-central1:metabase-db-staging, database flowpos_staging
  • production: barto-prod:us-central1:metabase-db-production, database flowpos_production

Start a Cloud SQL Auth Proxy tunnel per environment (needs gcloud auth login + Cloud SQL Client IAM role on your account):

# Ports must match whatever STAGING_DATABASE_URL / PRODUCTION_DATABASE_URL
# already use in your .env (commonly 5443 / 5444).
cloud-sql-proxy barto-dev:us-central1:metabase-db-staging --port 5443 &
cloud-sql-proxy barto-prod:us-central1:metabase-db-production --port 5444 &

Then build STAGING_DATABASE_URL/PRODUCTION_DATABASE_URL pointing at 127.0.0.1:<port> instead of the Cloud SQL unix socket, using the same username/database as that environment's Doppler DATABASE_URL secret — but note the password embedded in DATABASE_URL does not match the standalone DB_PASSWORD secret in the same config (confirmed for both stg and prd); always decode the password out of DATABASE_URL itself, not DB_PASSWORD:

postgresql://flowpos_staging:<password from stg DATABASE_URL>@127.0.0.1:5443/flowpos_staging
postgresql://flowpos_production:<password from prd DATABASE_URL>@127.0.0.1:5444/flowpos_production

These tunnels are session-scoped — they must stay running for the duration of any staging/production pull/push script call, and the DB URLs stop working the moment the tunnel process exits.

Known-good business_id for verification

--verify-business-id (and manual testing via a card's Metabase URL) needs a business_id that actually has rows in the target environment — a stray or copy-pasted UUID from a browser URL may not exist in that environment at all (this has happened: a business_id from a local dashboard URL turned out to exist in none of local, staging, or production, costing a round of DB queries to discover).

For every local Metabase change, use $METABASE_TEST_BUSINESS_ID_LOCAL from the repo-root .env. Staging/production use the matching vars. Do not hardcode the UUID in prompts or invent a new one — expand the env var when calling the push script:

# From repo root .env (source or export as needed for your shell)
METABASE_TEST_BUSINESS_ID_LOCAL="2dc1d79f-46f9-4cea-bc2b-b4430958b853"
METABASE_TEST_BUSINESS_ID_STAGING="f13a2de3-2ba1-4996-85d1-4fb8d2a978c7"
METABASE_TEST_BUSINESS_ID_PRODUCTION="736c336d-7dfb-4414-a00e-77ea9330b0c2"

# Local verification example
tsx src/metabase/push-metabase-card.ts --env local --definition <code> \
--verify-business-id "$METABASE_TEST_BUSINESS_ID_LOCAL"

These are FlowPOS's own internal FIXX platform-operator business (one row per environment, business.is_platform_operator = true — see apps/docs/docs/dev/platform-billing/access-control.md), reused here purely as a convenience because it reliably has real invoice/payment/AR data in every environment. Do not treat this as license to hardcode these UUIDs in application code — the platform-billing docs already warn against that for guards/services, since the actual per-env FIXX id is set by migration (2026-07-24t11-00-00-flag-fixx-platform-operator.mjs) and should be looked up via is_platform_operator = true, not assumed. This section's usage is scoped to manual/scripted Metabase verification only.

If FIXX ever stops having relevant data for the card you're testing (e.g. a module unrelated to platform billing), fall back to querying that environment's DB directly for a business with rows in the relevant table.

Fallback: direct REST edits for one-off exploratory changes

For a quick exploratory edit that doesn't need to survive promotion — or before you've decided a fix is worth making permanent — Claude can still edit a card directly via the REST API, the same way the versioned scripts do underneath:

  1. A Metabase API key for the environment you want to change.
    • Local: http://localhost:3002 → Settings → Admin settings → API Keys → Create API Key.
    • Staging/production: same path on that environment's Metabase URL. These are already in Doppler as METABASE_API_KEY_AGENT_{STAGING,PRODUCTION} — prefer syncing them via scripts/download-doppler-secrets.sh over creating a new key.
    • Hand the key to Claude by having it write the value to a file under its session scratchpad (outside the repo), not by committing it anywhere. For a local-only dev key, pasting it directly in chat is also fine — it has no production exposure.
  2. What to change, in plain terms — e.g. "hide the raw customer_id column and format the balance as currency." Point to the card/dashboard by name if you don't know the numeric ID; Claude can look it up via GET /api/dashboard and GET /api/dashboard/:id.
  3. Which environment(s) — local first is the default and safest. Ask explicitly if you also want the same change applied to staging/production.
  4. A business_id to test against, if you want Claude to verify the query actually executes and returns sane data (recommended for any SQL change). For local work, pass $METABASE_TEST_BUSINESS_ID_LOCAL from .env — not a UUID from a browser URL.

What Claude actually does:

  1. GET /api/card/:id (or search dashboards by name first if the ID is unknown) to fetch the current dataset_query and visualization_settings — never assume the current state matches an old export or a different environment's copy.
  2. Edit only the relevant part of the response in place (e.g. the SQL string inside dataset_query.stages[0].native) and PUT the full object back. Newer Metabase versions store native queries in a stages-based pMBQL format rather than the older {"native": {"query": ...}} shape — mutating the fetched object avoids schema mismatches from hand-writing the payload.
  3. POST /api/card/:id/query with a real business_id parameter to confirm the change actually runs and returns the expected shape — a successful PUT only means the SQL was accepted, not that it's correct.
  4. If editing a dashboard's presentation, also check the dashcard-level visualization_settings (inside GET /api/dashboard/:iddashcards[].visualization_settings) — a non-empty override there takes precedence over the card's own settings and can make a card look unchanged even after the card itself was fixed.

Gotchas hit in practice

  • Stale browser view: if a dashboard doesn't look updated after a confirmed API change, hard-refresh (Cmd+Shift+R) before assuming the fix didn't apply — Metabase/browser caching can show an old render.
  • Don't touch Metabase's internal app database directly (H2 file locally, or its Postgres metadata DB in the cloud) — always go through the REST API.
  • Test locally first, then repeat the same change against staging and production once verified, same as the promotion order in the Export/Import Guide.
  • Registration code mismatch after a bulk import: push-metabase-card.ts --env <env> --definition <code> looks up the target card by matching metabase_card.code to a metabase_card_instance row for that environment — if that row doesn't exist under the same code, the script can't find the card, even though the card itself exists in that environment's Metabase. This bites after running import-all-cards.ts --env staging/--env production to bulk-register a new environment: it auto-derives codes as <collection-slug>-<name-slug> (e.g. flowpos-ar-top-10-customers), which won't match a curated card's hand-chosen code (e.g. ar-top-10-customers) even when it's the same underlying card. Fix by renaming the environment's metabase_card.code to match the curated code (UPDATE metabase_card SET code = '<curated-code>' WHERE code = '<auto-derived-code>') — safe as long as the curated code isn't already taken in that environment's database — then re-run push-metabase-card.ts --dry-run to confirm it resolves. Each environment's metabase_card/metabase_card_instance tables live in that environment's own app database, so this check (and fix) has to be repeated per environment.

Worked example: syncing "Top 10 customers" to staging

A real run of the loop above, including the bulk-import gotcha.

Symptom — the "Top 10 customers" card on the Accounts Receivable Dashboard looked different on staging than local: local showed clean customer_name / legal_name / outstanding_balance ($) columns, staging showed raw customer_id / customer_tax_name / customer_first_name / ... columns.

Diagnosis — fetched both cards' dataset_query directly and diffed the SQL:

curl -s -H "x-api-key: $METABASE_API_KEY_AGENT_DEV" \
"http://localhost:3002/api/card/121" | jq -r '.dataset_query.stages[0].native // .dataset_query.native.query'

curl -s -H "x-api-key: $METABASE_API_KEY_AGENT_STAGING" \
"$STAGING_METABASE_URL/api/card/275" | jq -r '.dataset_query.stages[0].native // .dataset_query.native.query'

Local's query (card 121, code ar-top-10-customers) merges the name fields:

SELECT
b.customer_id,
COALESCE(NULLIF(TRIM(CONCAT_WS(' ', c.first_name, c.last_name)), ''), c.tax_name) AS customer_name,
c.tax_name AS legal_name,
COALESCE(SUM(b.outstanding_base), 0) AS outstanding_balance
FROM accounts_receivable_invoice_balance_v b
JOIN customer c ON c.id = b.customer_id
WHERE b.business_id = {{business_id}}::uuid
AND b.outstanding_base > 0
AND b.status NOT IN ('paid', 'void')
GROUP BY b.customer_id, c.tax_name, c.first_name, c.last_name
ORDER BY outstanding_balance DESC
LIMIT 10;

Staging's query (card 275) was still the pre-fix version — same underlying card, never pushed:

SELECT
b.customer_id,
c.tax_name AS customer_tax_name,
c.first_name AS customer_first_name,
c.last_name AS customer_last_name,
COALESCE(SUM(b.outstanding_base), 0) AS outstanding_balance
FROM accounts_receivable_invoice_balance_v b
JOIN customer c ON c.id = b.customer_id
WHERE b.business_id = {{business_id}}::uuid
AND b.outstanding_base > 0
AND b.status NOT IN ('paid', 'void')
GROUP BY b.customer_id, c.tax_name, c.first_name, c.last_name
ORDER BY outstanding_balance DESC
LIMIT 10;

Root cause — staging had never been registered in metabase_card/metabase_card_instance at all, so it was bulk-registered via import-all-cards.ts --env staging, which auto-derived the code flowpos-ar-top-10-customers for card 275 — not ar-top-10-customers, the code the versioned definition (scripts/metabase/card-definitions/ar-top-10-customers.{sql,json}) actually uses. This is the "Registration code mismatch after a bulk import" gotcha above.

Fix — renamed staging's metabase_card.code to match, confirmed via --dry-run, then pushed for real:

psql "$STAGING_DATABASE_URL" -c \
"UPDATE metabase_card SET code = 'ar-top-10-customers' WHERE code = 'flowpos-ar-top-10-customers';"

cd packages/backend/scripts
tsx src/metabase/push-metabase-card.ts --env staging --definition ar-top-10-customers --dry-run
# diff matched the "current vs desired SQL" shown above — confirmed the fix would apply cleanly

tsx src/metabase/push-metabase-card.ts --env staging --definition ar-top-10-customers \
--verify-business-id "$METABASE_TEST_BUSINESS_ID_STAGING"
# ✅ Pushed to staging card 275.
# ✅ Verified: 10 row(s), columns [customer_id, customer_name, legal_name, outstanding_balance]

Result — confirmed visually on https://app.staging.flowandgrow.tech/forms/AccountsReceivableDashboardPage (after a hard refresh) — staging's "Top 10 customers" now matches local: customer_name, legal_name, outstanding_balance ($) formatted as currency.

Example prompts

  • "Update the 'Top 10 customers' card on the AR dashboard — hide the raw customer_id column and merge the name fields into one Customer column."
  • "The Total AR balance query includes draft invoices, it shouldn't — find the underlying query/view and fix it."
  • "Apply the same column cleanup we just did on card X to card Y, which has the same issue."
  • "Push the ar-top-10-customers card definition to local, verifying with $METABASE_TEST_BUSINESS_ID_LOCAL from .env."
  • "Push the ar-top-10-customers card definition to staging."