Migrating Tableau dashboards: Streamlit code vs. Sigma with a parity gate
Same migration, opposite bets: rebuild as Streamlit code you own, or as Sigma workbooks behind a parity gate.
TL/DR: Two agent skills migrate Tableau dashboards. One rebuilds them as Streamlit code; the other rebuilds them as governed Sigma workbooks behind a warehouse-parity gate. Same delivery mechanism, opposite philosophies on where the dashboard lands and how much to automate.
I had a pile of legacy Tableau workbooks to retire. And on Tuesday this week (2026-06-16) I stumbled upon newly open-sourced marketplace skills (by TJ Wells) that migrate Tableau (and seven other BI tools) to Sigma. Both tasks utilize agent skills: Markdown playbooks plus scripts that a coding agent (Claude Code, ❄️ CoCo, OpenCode) executes. I built my skill to land dashboards in a Streamlit app on Snowflake. Sigma's skills land them in Sigma (duh). Putting the two side by side is the interesting part: one choice (code vs platform) cascades into everything else.
Approach 1: Tableau to Streamlit, code I own
My skill is (very creatively) called build-dashboard-page. It's deliberately app-specific (~290 lines + two Python scripts), built for one concrete app: a Streamlit multipage app deployed on Snowflake Container Services. Each Tableau dashboard becomes one pages/<Name>.py file that queries Snowflake (or a DuckDB copy of the same data on the same SPCS instance). I wrote about the surrounding app in an earlier post:

The whole workflow rests on one rule:
Ground truth is Tableau, not the plan bullet. Every Tableau workbook encodes years of decisions about metrics, filters, and formulas. Skipping them produces coherent-but-wrong SQL.
I measured this: guessing the SQL from a plan description produced wrong numbers, while reverse-engineering the workbook first produced correct numbers in fewer agent turns. So the skill front-loads inspection across six phases: scope and name the page, inspect the .twb, verify the real Snowflake source, craft a delegation prompt, delegate the SQL, then wire the Python page and smoke test it.
Phase 2 runs a stdlib-only parser (inspect_tableau_workbook.py) over the .twb / .twbx / .tds / .tdsx. It dumps datasources, embedded Custom SQL, referenced tables, calculated fields with formulas verbatim, parameters, and per-worksheet filter shelves and column dependencies. The point is to extract every calc field exactly as Tableau wrote it, because I refuse to paraphrase those 😜
Phase 3 has a gotcha worth calling out: the .twb's sqlproxy connection points at Tableau Cloud, not the real warehouse. I have to open the .tdsx, find the <connection class="snowflake"> block, and DESCRIBE the real source. Then pick a query layer:
| Query against | When |
|---|---|
Pre-built views (prefixed V_* objects in SQL) | Whenever they exist. They already encode the joins and filters the workbook assumes. |
| Raw star schema | When the workbook uses LOD calcs or needs fields the views don't expose. |
| Semantic view | Rarely. Built for Cortex Analyst, fixed metrics, no LOD. |
Why delegate the SQL?
The main agent does not write the SQL. It writes a prompt and hands it to Snowflake CoCo via a separate skill. Why? Because CoCo runs the query, validates it against the live warehouse, and flags anomalies in the same turn. The handoff mechanic is its own story:

The prompt is where the work goes. It carries project context (CHF, German column names, read-only), the "Tableau is ground truth" directive, the Snowflake source named explicitly down to columns, every Tableau calc field quoted verbatim, per-query output-shape specs, validation rules with expected value ranges, and f-string placeholders for the date filters. The maxim baked into the skill is simple: invest in the prompt and save several turns of rework later. The run itself is one command:
cortex -p .cortex-handoff/<page-slug>/prompt-01.txt \
-c sysadmin --output-format stream-json --bypassValidated .sql lands in two places: .cortex-handoff/ as the audit trail (prompt + JSONL response + session id) and sql/<page-slug>/ as the runtime copy. Then the Python page loads templates once at import and queries through a SQL-text-aware cache, so editing a .sql file busts the cache and you never serve a stale DataFrame:
SQL_TEMPLATES = {p.stem: p.read_text() for p in (SQL_DIR).glob("*.sql")}
require_role(["SYSADMIN", "FUNDRAISING_READER", "ANALYTICS_ADMIN"])
df = run_sql_template(
SQL_TEMPLATES["monthly_donations"],
start_date=start,
end_date=end
)Two calc-field traps that silently produce wrong SQL
Two Tableau behaviors will quietly poison a port, so the delegation prompt bakes in guards for both.
MONTH([Date])collapses years. Tableau'sMONTH()date-part buckets Jan-2024 and Jan-2025 into one "January" point (12 buckets across all years), whileDATE_TRUNC('month', d)keeps the year (24 points across 2 years).- Tableau's
IF/ELSEIF ... ELSEcatches NULL in the ELSE arm. A NULL routed through a chained test falls through to the last branch, because everyNULL >= nis unknown. Add an explicit guard when the source is nullable:
case
when amount is null then null -- guard first, or NULLs pile into 'C'
when amount >= 5000 then 'A'
when amount >= 1000 then 'B'
else 'C'
endWhat "verified" means here
There is no automated data-parity gate comparing my Streamlit numbers to Tableau's. Correctness is enforced earlier, at SQL-generation time: CoCo validates each query (row counts, sample values, flagged anomalies) and I eyeball the values against expected business ranges. The smoke test (an AppTest harness printing [smoke] OK) only proves the page renders, not that the numbers match.
Approach 2: Tableau to Sigma, parity is the hard gate
TJ Wells' sigma-migration-skills takes the opposite bet. It's a marketplace plugin (MIT-licensed, Ruby 65% / Python 28%): not one converter but eight, one per source BI tool, each as a matched pair of skills (a converter and an assessment).
Two slogans drive it: "Translate, never fake" means rewrite what you can and flag what you can't. And "parity is the hard gate" means a migration is not GREEN until Sigma's numbers match the warehouse's numbers, chart by chart. That gate is only possible when Sigma queries the same warehouse the Tableau source reads, so a value gap is a real bug, not data drift.
Every converter follows the same phased arc:
- Discover: pull the source model plus sheets/dashboards.
- Translate: map calcs/LODs/table calcs to Sigma formulas using a converter plus a gap‑scout subagent.
- Data model: build the Sigma data model (tables, relationships, metrics), reconciled to the warehouse.
- Workbook: rebuild pages/visuals as a Sigma workbook, with layout applied last.
- Parity: query Sigma vs the warehouse and call it green only when they match.
The Tableau plugin ships three skills: tableau-assessment (inventory the estate, score complexity, rank a migration shortlist), tableau-to-sigma (convert one workbook end to end), and tableau-vds-to-cdw (a data-landing bridge that pulls a Tableau-only extract via the VizQL Data Service API and materializes it as a warehouse-native table when the source isn't in the warehouse yet).
The hard part: translating formulas, not generating SQL
Here's the deepest divergence. My skill treats SQL as a delegated, validated black box. The Sigma skill cannot, because a Sigma workbook formula isn't SQL, it's Sigma's own language. So the skill builds an entire formula-translation engine and engineers around its silent-failure modes.
The phase that earns the most respect is window and table calc translation. The mainstream Tableau window family (RUNNING_*, bounded WINDOW_*, RANK*, INDEX, LOOKUP, TOTAL) is auto-translated to Sigma-native window math emitted as chart-element viz formulas on the y-axis: one base data element, zero custom SQL, validated 930/930 cells exact. A "manual residue" set (WINDOW_MEDIAN/PERCENTILE/CORR, PREVIOUS_VALUE, SIZE, FIRST/LAST) is routed to custom SQL elements instead, because window functions silently fail as data-model calc columns. A recurring footgun the skill designs around 😎
A few more mechanics worth knowing:
- Gap scan before anything: a
.twbis statically scanned and every feature bucketed auto / hint / manual / unhandled, so expectations are set up front. Each unhandled feature gets a dedicated subagent that proposes a Sigma equivalent, validates it live against the Sigma API, and persists a learned rule. - Learned rules per machine: a validated gap solution lands in
~/.tableau-to-sigma/learned-rules.yamland is applied before built-in translators on future conversions. The converter gets smarter the more it's used. - {FIXED} LODs auto-translate to a hidden two-level grouped helper element; nested LODs become a helper-element chain.
- Layout is mandatory and applied last: Without a top-level layout, Sigma stacks every tile in one column, so a hard gate rejects a layout-less workbook.
- Visual parity too: the final phase exports the Sigma page as PNG and the agent reads it back to catch regressions (dropped log axis, palette drift) that value-parity misses.
- RLS handled by the skill, not the converter: Row/column security is detected, then provisioned as Sigma user attributes and teams behind an explicit Port / Customize / Skip gate. Skipping is loud.
Code vs platform
Strip away the detail and the two skills disagree on 5 things:
| Dimension | build-dashboard-page (Streamlit) | sigma-migration-skills (Sigma) |
|---|---|---|
| Where it lands | Code in my repo, my infra (SPCS) | Governed object on a vendor platform (Sigma) |
| How it handles SQL / formulas | Delegate SQL to CoCo, validated | Translate to Sigma formulas, gap-scouted |
| How it handles window calcs / LODs | Handled in SQL, no special machinery | Sigma-native window math + LOD helpers |
| How it verificies | Per-query validation + render smoke test | Warehouse parity (value + PNG) per chart |
| Ground truth | The Tableau workbook | The source warehouse |
The trust models are the cleanest summary. My Streamlit app trusts validated SQL at generation time. Sigma trusts nothing until a chart-by-chart diff against the warehouse passes. And the generality cost is real: my skill is ~290 lines because it targets one app; the Sigma plugin is ~75 files of Ruby and Python (per tool migrated from) because it must fold any workbook into a foreign object model with a parity guarantee.
Both are agent skills, which is the through-line. Pick code when you want to own the thing and verify at the SQL, and pick the platform when you want governance and a parity gate that refuses to lie. The migration is similar, the result is different. 😎

