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 (seescripts/metabase/card-definitions/README.md). Per-environment numeric Metabase IDs are deliberately not stored here — see below.metabase_card/metabase_card_instancedatabase tables — the registry mapping a card's stablecodeto its numeric Metabase card ID per environment (local/staging/production). Managed through the/system/MetabaseCardInstancePageadmin screen infrontend-pwa, the same waymetabase_dashboard_instanceis 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/MetabaseCardInstancePageto search cards and set per-env IDs. These tables exist purely for this tooling; they are not the same system asmetabase_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:
-
Register the card's per-environment IDs once via the admin UI (or ask a teammate who has access).
-
tsx src/metabase/pull-metabase-card.ts --env local --card-id <id> --code <code> --name "<name>" --dashboard-id <uuid>(run frompackages/backend/scripts) — fetches the current, already-fixed local card and writes the.sql/.jsonpair. -
Edit the
.sql/.jsonpair directly for further changes, or re-runpullafter fixing the card in Metabase's UI locally. -
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. -
Real push (local first, then staging, then production): each real push requires
--verify-business-id— a successfulPUTonly 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 |
|---|---|
| local | METABASE_API_KEY_AGENT_DEV (not _LOCAL) |
| staging | METABASE_API_KEY_AGENT_STAGING |
| production | METABASE_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, databaseflowpos_staging - production:
barto-prod:us-central1:metabase-db-production, databaseflowpos_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:
- 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 viascripts/download-doppler-secrets.shover 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.
- Local:
- 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/dashboardandGET /api/dashboard/:id. - Which environment(s) — local first is the default and safest. Ask explicitly if you also want the same change applied to staging/production.
- 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_LOCALfrom.env— not a UUID from a browser URL.
What Claude actually does:
GET /api/card/:id(or search dashboards by name first if the ID is unknown) to fetch the currentdataset_queryandvisualization_settings— never assume the current state matches an old export or a different environment's copy.- Edit only the relevant part of the response in place (e.g. the SQL string
inside
dataset_query.stages[0].native) andPUTthe full object back. Newer Metabase versions store native queries in astages-based pMBQL format rather than the older{"native": {"query": ...}}shape — mutating the fetched object avoids schema mismatches from hand-writing the payload. POST /api/card/:id/querywith a realbusiness_idparameter to confirm the change actually runs and returns the expected shape — a successfulPUTonly means the SQL was accepted, not that it's correct.- If editing a dashboard's presentation, also check the dashcard-level
visualization_settings(insideGET /api/dashboard/:id→dashcards[].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 matchingmetabase_card.codeto ametabase_card_instancerow 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 runningimport-all-cards.ts --env staging/--env productionto 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'smetabase_card.codeto 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-runpush-metabase-card.ts --dry-runto confirm it resolves. Each environment'smetabase_card/metabase_card_instancetables 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_LOCALfrom.env." - "Push the ar-top-10-customers card definition to staging."