| name | metabase-to-sigma |
| description | Migrate Metabase content to Sigma. Use when the user has Metabase questions, models, or dashboards and wants to recreate them in Sigma. Converts MBQL cards/models (+ database metadata) โ Sigma data model and dashboards โ Sigma workbooks, translating MBQL expressions/aggregations and flagging constructs with no clean Sigma analog. Discovery via the Metabase REST API (API key or session token) โ works on open-source and Pro/EE alike. |
| user-invocable | true |
Metabase โ Sigma migration
Convert Metabase models + questions into a Sigma data model, then convert the
dashboards that sit on them into matching Sigma workbooks. Translate what maps
cleanly; flag what doesn't (cum-sum/offset windows, saved segment refs, funnel/gauge
viz, click behaviors) instead of emitting wrong logic.
Status: production-validated end to end. Extraction proven against a live
7k-card / 1.5k-dashboard Metabase Cloud estate (v1.61.4 โ 100% pMBQL); the
Sigma BUILD path live-validated with exact MCP-query parity, including models,
nested questions, combo charts, controls, and exact-grid layout
(refs/design-notes.md ยง9โยง10d). BigQuery paths/casing are live-verified on
the Sigma side (ยง10c).
This skill is customizable in plain language. Read
~/.metabase-to-sigma/preferences.md if it exists at the start of every run,
restate the active preferences briefly, and honor them throughout (they may
override any default EXCEPT the verification gates). When the user corrects an
output or states a preference mid-run, OFFER to persist it โ see
refs/customization.md for the three tiers (preferences / learned formula
rules / converter changes).
Read refs/ before relying on shapes: design-notes.md (translation surface +
decisions + production findings), rest-api.md (endpoints + auth + version
gotchas), mbql-shapes.md (real card/dashboard JSON structures incl. pMBQL),
expression-dsl.md (MBQL โ Sigma formula mapping table), template-tags.md
(native {{tags}} โ Sigma controls), control-parity.md (SHARED cross-plugin
control-wiring contract: the control lint, the control-scope.json sidecar, the
flip test, and the verified target gotchas). For canonical Sigma data-model +
workbook spec shapes, defer to the companion sigma-data-models /
sigma-workbooks skills.
Prerequisites
- Metabase REST access โ an API key (v49+: Admin โ Settings โ Authentication โ
API keys; preferred, durable) or a username/password session. Capture either with
scripts/get-metabase-session.sh. Open-source Metabase is fully sufficient โ no
Pro/EE features required (serialization export is EE-only; this skill doesn't use it).
- Sigma API token (via the
sigma-api skill) to POST the data model + workbook.
- The same warehouse on both sides. Sigma reads the warehouse live; parity only
means something when the Sigma connection reaches the database Metabase queries.
(Metabase's bundled H2 Sample Database is NOT reachable from Sigma โ pick content
on a real warehouse, or land the data first.)
- Know your warehouse dialect. The converter auto-detects it from the Sigma connection
(
--connection <id> triggers a GET /v2/connections/<id> lookup), or pass --warehouse
explicitly: bigquery | snowflake | databricks | redshift | postgres | athena.
Required for correct array-aggregation rewrites (BigQuery ARRAY_AGG โ array_to_string,
Snowflake โ LISTAGG, Databricks collect_list โ array_join, etc.). Without it,
native SQL cards with array aggregations will render as blank cells in Sigma.
- Node for the converter (
converter/: npm install once).
Phase 0 โ Discover (Metabase REST)
export MB_BASE="https://<host>"
eval "$(scripts/get-metabase-session.sh)"
scripts/metabase-discover.sh databases
scripts/metabase-discover.sh metadata 2 > metadata.json
scripts/metabase-discover.sh collections
scripts/metabase-discover.sh items 5
scripts/metabase-discover.sh card 123 > orders-model.card.json
scripts/metabase-discover.sh dashboard 9 > exec.dashboard.json
- Always fetch
metadata <dbId> first โ MBQL references columns by integer field
id; without the metadata map the converter falls back to per-card result_metadata
names (lossy, warned).
- Models (
type: "model", or dataset: true pre-v50) are the semantic layer โ fetch
them even when no dashboard uses them directly; questions stack on them via
source-table: "card__N".
- For estate-wide inventory + a migration shortlist, run the
metabase-assessment
skill first โ its specs/ directory feeds this skill directly.
Phase 1 โ Convert models/cards โ Sigma data model
cd converter && npm install
node --import tsx/esm cli.ts ../bundle.json --metadata ../metadata.json \
--connection <SIGMA_CONN> --database <DB> --schema <SCHEMA>
bundle.json is { "cards": [ <card JSONs> ] } (or pass a single card file). Emits
the Sigma data-model JSON on stdout; stats + warnings on stderr. Read the warnings
aloud to the user โ they are the parts that need manual authoring (cum-sum/offset
windows, segment/metric refs to inline, binned breakouts, field-filter SQL tags).
Phase 1.5 โ Reuse an existing DM? (avoid sprawl)
Before POSTing a NEW data model in Phase 2, check whether an existing Sigma DM already
covers the same warehouse tables (don't add a 4th near-identical DM for the same schema):
python3 scripts/metabase-dm-signature.py --dm-spec dm.json --out dm-signature.json
eval "$(scripts/get-token.sh)"
ruby scripts/find-or-pick-dm.rb --workbook-signature dm-signature.json \
--out dm-match.json --auto-pick
- Score โฅ 0.6 โ ASK the user reuse-vs-new: surface the candidate name, matched
cols (N/M), and the inherited-extras warning. If they reuse, run a shape preflight
(read the candidate spec back; every column the dashboard references must resolve with
no
type=error), then skip Phase 2 and run Phase 3 against the matched
recommended_dm_id.
- Score < 0.6 โ POST new (Phase 2) and TELL the user no reusable DM was found.
Phase 1.9 โ Choose where to build (ask first when no destination given)
Don't pick the destination for the user. If they didn't supply a --folder <id>, ASK before the Phase 2 POST:
node scripts/pick-destination.mjs list โ { workspaces, folders (editable, with parentName), myDocuments }
- Let the user pick ONE: a workspace (its
id lands content in the workspace root), an existing folder, My Documents (when non-null โ null for service tokens), or create a new folder: node scripts/pick-destination.mjs create --name "<name>" [--parent <workspace-or-folder-id>]
- Pass the chosen id as
--folder <id> to every post-and-readback.mjs call (DM + workbook). folderId accepts a workspace id or a folder id.
If a destination is already supplied, honor it silently โ don't ask.
Phase 2 โ POST the data model + read back ids (hard gate)
eval "$(scripts/get-token.sh)"
node scripts/post-and-readback.mjs --type datamodel --spec dm.json \
--folder <folderId> --out dm-map.json
POSTs to /v2/dataModels/spec, reads the spec back, and fails on any type=error
column (a spec can POST 200 yet have formulas that don't resolve at query time โ the
readback scan catches it, derived view included). dm-map.json carries the real
dataModelId + element ids (Sigma reassigns them on POST). Do not proceed past a
non-zero exit.
Phase 3 โ Convert the dashboard โ Sigma workbook, wired to the DM
node --import tsx/esm cli.ts ../exec.dashboard.json --metadata ../metadata.json --dm <dataModelId> \
--layout-out hints.json --control-scope-out control-scope.json > wb.json
node scripts/remap-wb-to-dm-ids.mjs --wb wb.json --dm-id <dataModelId> --dm-spec dm.json --out wb.remapped.json
ruby scripts/lib/preflight_lint.rb wb.remapped.json
node scripts/post-and-readback.mjs --type workbook --spec wb.remapped.json --folder <folderId>
node scripts/apply-layout.mjs --workbook <workbookId> --hints hints.json
Preflight (mandatory): ruby scripts/lib/preflight_lint.rb wb.remapped.json exits 1 with a precise message on the two migration-killer bugs โ a table with aggregate columns + dimensions but no groupings (renders raw detail rows instead of an aggregated summary), and a malformed control (missing id/controlId/controlType or the flat list value fields source/mode/selectionMode/values). Fix every violation first; never conclude a feature is "unsupported" from an Invalid kind error โ it means the inner fields are wrong. Verified shapes: sigma-workbooks controls.md/tables.md.
Each dashcard becomes the matching Sigma element sourced from the migrated DM element
(KPI/bar/line/area/pie/combo/scatter/table/pivot/map; text cards โ text elements;
funnel/gauge/progress/waterfall โ flagged tables). The converter emits each element's
source.elementId as the source card/table name (a placeholder) โ
remap-wb-to-dm-ids.mjs rewrites those to real ids from Phase 2's readback (native
cards all read back "Custom SQL", so it falls back to column-set fingerprints and
repairs every formula ref against the live DM columns).
Controls (full contract: refs/control-parity.md). Metabase dashboard parameters
declare their card targets explicitly (parameter_mappings) โ the converter wires
each mapping and emits the control-scope.json sidecar (--control-scope-out,
keep it next to the workbook spec: post-and-readback and gate 7 pick it up there):
- scalar params (string/number equality, incl. static-list โ segmented grain
switchers with values + defaults) โ hidden boolean
[Col] = [slug] + element filter;
- range params (
date/* โ date-range with flat mode: "between",
number/between โ number-range) โ REAL control filters targets, re-rooted
through a hidden base table on a trailing Data page (control targets may
only point at table elements; list/scalar targets on datetime columns are
silently stripped โ both verified cross-plugin gotchas);
- variable-tag params (drive
{{tags}} in card SQL) โ --dm-spec emits
controlโDM-parameter bindings; if the org rejects them, post-and-readback
drops those controls (decorative controls are exactly what gate 7 blocks โ
the DM {{tag}} controls still carry the filter; sync a workbook control in
the UI when the org enables DM-parameter targeting, or pass
--keep-rejected-bindings to keep them and sync by hand);
- unmapped params + unwirable field-filters โ NO control, loud warning
(flag, never furniture).
post-and-readback (workbook) finishes by running the SHARED layout + control lints
on the readback spec โ fix violations (repair recipes in refs/control-parity.md)
or annotate genuine narrow intent in control-scope.json before moving on.
Metabase's 24-col dashcard grid maps 1:1 onto Sigma's layout โ
apply-layout.mjs --hints reproduces the exact geometry, confirms it survives
readback, and re-runs the layout lint (without --hints it falls back to a clean
generic layout).
Phase 4 โ Verify parity + the seven gates (hard gate โ the real proof)
node scripts/assert-parity.mjs --plan --type workbook --id <workbookId>
node scripts/assert-parity.mjs --check --actual actual.json --expected metabase.json --tol 0.01 \
--workdir <workdir> --census '{"zones_total":N,"charts_built":M,"zones_unmatched":0,"unmatched_zone_names":[]}'
ruby scripts/assert-phase6-ran.rb --workdir <workdir> --workbook-id <workbookId>
Expected values must be LIVE: re-run the Metabase cards (POST /api/card/{id}/query)
at verification time โ a baseline captured earlier drifts as warehouse rows land, and
the diff reads as a phantom parity failure.
assert-parity --check writes the parity-final.json sentinel into --workdir
(post-and-readback already wrote wb-ids.json + posted-workbooks.jsonl there);
derive the --census counts from the converter stats (dashcards converted vs
elements built โ name any legitimately unbuildable zones). Then
assert-phase6-ran.rb is the GREEN gate: parity ran (1), no orphan workbooks
(2), no error-typed columns (3), layout applied (4), tile census (5), layout lint
(6), control lint honoring control-scope.json (7). Exit 0 or it isn't done.
Flip test (runtime control evidence โ REQUIRED after any hand-repaired wiring,
recommended always; see refs/control-parity.md for why MCP cannot do this):
ruby scripts/probe-controls.rb --workbook-id <workbookId> --check-out-of-closure
A mapped card's export must CHANGE under parameters:{<controlId>: <value>}; an
unmapped same-page card must NOT (no leak).
Visual gate (layout, control widgets, chart marks โ things data queries can't see):
export each page as PNG and LOOK at it:
Check: controls render as the right widget (segmented grain switcher, defaults filled),
elements sit at the Metabase grid positions (not stacked), charts show marks (an empty
chart with a title = a column/axis problem the readback scan can miss).
A migration is GREEN only when (a) assert-parity --check passes, (b)
assert-phase6-ran.rb exits 0 (all seven gates, control lint included), AND (c) the
workbook came back with a clean layout (apply-layout.mjs reported
layoutOnReadback: true) โ never on a 200 POST alone. metabase.json = the numbers
from the Metabase cards (run each card via POST /api/card/{id}/query โ the one
non-GET this skill may use, read-only in effect โ or read them off the dashboard).
Mind caching: Metabase serves cached results by default; Sigma reads live. A delta
that matches rows landed since the cache filled is freshness, not a failure.
After parity passes, tell the user this in the conversation:
"Migration complete. Before I wrap up, I'd like to send an anonymous usage ping so we can track which migration skills are being used. It records: tool name, your Sigma region, an anonymized org fingerprint (a hash of your client ID โ not the credential itself), migration duration, and success. No workbook names, SQL, column names, or any customer data is included. See TELEMETRY.md for the exact payload. Just say 'skip' if you'd prefer not to send it."
If the user does not object, run:
node scripts/report-telemetry.mjs --duration <elapsed_seconds>
What converts, what's flagged (never faked)
Converted (per the contract in refs/expression-dsl.md โ fixture-tested; extraction
production-validated, Sigma POST shapes pending first live build):
- pMBQL ("lib/" MBQL) โ the modern wire format (100% of the reference production
estate) โ normalized to legacy MBQL at intake (
converter/pmbql-normalize.mjs;
the server's legacy_query is preferred when present). Multi-stage queries are
flagged, never mistranslated.
- MBQL questions/models โ DM elements: explicit
joins โ join sources
(left/right/inner/full), expressions โ calc columns, aggregation โ metrics
(incl. named aggregation-options, count-where/sum-where โ CountIf/SumIf,
share โ ratio + % format), temporal-unit breakouts โ DateTrunc columns.
- FK metadata โ DM relationships (+ derived join view; the relationship's own key
column is skipped โ a cross-element join-key passthrough compiles to type
error).
- Native SQL questions โ first the converter tries to auto-remodel to a NATIVE
Sigma data model: a card that is a single
SELECT over warehouse table(s) (no
CTE / subquery / CASE / window / set-op, only field-filter tags, real WHERE only on
those tags, no LIMIT) is re-expressed as a structured query and built as a
table/join model โ NO custom SQL โ so its columns are exposed and dashboard filters
reproduce as REAL Sigma controls + element filters (live-proven). SQL too complex to
remodel falls back to a Custom SQL element (no element name, bare [Display Name]
refs; dialect verbatim โ BigQuery project.dataset.table near-verbatim). In the
fallback: plain {{text/number/date/boolean}} tags keep their {{tag}} + emit a
control (โ ๏ธ a workbook control bound only to a DM-SQL {{param}} is INERT until
wired to the DM parameter in the UI โ live-disproven); field-filter tags โ
1=1 + recreated as control/element filter when the column is in the result set;
{{#card}} inlined when tag-free; [[โฆ]] kept/dropped per Metabase semantics
(always warned). Filters that can't be reproduced are reported in the result's
unreproducibleFilters (reason + manual-remodel hint) โ never shipped as dead
controls. See refs/template-tags.md.
- Dashboards โ workbooks: one page per tab, 24-col grid 1:1, scalar/smartscalar โ
KPI (
value: {columnId}), pivot โ pivot-table (rowsBy/columnsBy {id} objects +
bare-string values), row display โ horizontal bar, maps โ region-/point-map,
text/heading cards โ text elements (markdown carries over), parameters โ controls +
per-card target filters. Parameters that drive native template tags (the DOMINANT
production pattern) are recorded in the result's parameterWiring + ONE aggregated
warning per parameter.
- Formats:
column_settings (currency incl. symbol, decimals, prefix/suffix) โ
Sigma d3 formats first, name/formula heuristics second; series_settings titles
rename series (colors flagged); table.column_formatting single threshold rules โ
conditionalFormats (gradient/range scales flagged).
Flagged with a warning (and a readable placeholder), never faked:
cum-sum/cum-count/offset (rebuild with CumulativeSum/window calcs in the
date-grouped consuming element), ["segment", id] / legacy ["metric", id] refs
(inline their MBQL from /api/segment/{id} / /api/legacy-metric/{id}), binned
breakouts (โ BinFixed/BinCount), multi-stage queries (โ chained Sigma elements),
click_behavior (โ Sigma actions, manual), smartscalar previous-period comparisons,
object detail views (โ flagged detail table), and viz with no native Sigma element:
funnel, gauge, progress, waterfall, sankey โ flagged table. Unknown MBQL ops emit
/* unmapped: <op> */ + a loud warning.
Security: Row-Level Security (sandboxing)
Row security is never silently dropped and never silently ported โ and it is handled
by the skill, not baked into the converted model. Metabase sandboxing is Pro/EE
only (GET /api/mt/gtap lists sandboxes; group-based). The converter only detects
and reports (security.json + a loud SECURITY: line) when sandbox data is provided;
on OSS there is nothing to detect โ but ask the customer anyway (RLS is sometimes
faked with per-group collections + duplicated filtered questions; inventory those by hand).
Flow (only when security was detected or found manually โ zero overhead otherwise):
- Convert + post the DM. Capture
dataModelId + security.json
(manual entries use the same shape: [{ "type": "row-filter", "name": โฆ, "expression": โฆ, "groups": [โฆ] }]).
- Gate (opt-in/out, default Port). Plain-English summary of each rule + proposed
Sigma user-attribute mapping โ Port / Customize / Skip. Reuse existing
Sigma user attributes/teams before creating new ones.
- Provision + apply with the shared engine:
eval "$(scripts/get-token.sh)"
python3 scripts/apply_sigma_rls.py --from-security security.json --dm-id <id>
python3 scripts/apply_sigma_rls.py --from-security security.json --dm-id <id> --provision --apply
- Assign per-user attribute values from Metabase group membership
(
GET /api/permissions/group/{id} lists members with emails โ reconcile to Sigma members).
Skip is loud: opting out leaves the migrated model showing ALL rows to everyone. Confirm first.
Gap scout โ close a flagged expression
For a flagged construct you want to actually resolve (an offset window, a segment ref,
an unmapped op), spawn the gap-scout subagent (scripts/gap-scout.md): it proposes
a Sigma formula, validates it against the customer's live Sigma via
scripts/scout-validate-and-persist.mjs, and on success persists the rule to
~/.metabase-to-sigma/learned-rules.json โ which the converter CLI auto-applies
before the built-in translator on the next run. If no formula validates, it returns
an opt-in scripts/escalate-gap.py command to file a tracking issue (ask first).
Customizing the skill (your org, your rules)
Everything above is the default behavior โ not the required one. Tell Claude what
you want different, in plain language, and ask it to remember:
- Run preferences (naming, folders, which tabs/cards, control widget choices,
layout taste) โ saved to
~/.metabase-to-sigma/preferences.md, read at the start
of every run.
- Formula translations specific to your SQL idioms โ learned rules
(
~/.metabase-to-sigma/learned-rules.json), validated live before persisting.
- Structural behavior (chart mappings, DM shape) โ converter changes with
fixture tests; share fixes back via
scripts/escalate-gap.py or a PR.
Both home-dir files survive git pull. Full guide + worked examples:
refs/customization.md.