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.

Share
Migrating Tableau dashboards: Streamlit code vs. Sigma with 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.

Both skills refuse to fake: mine stops if there's no Tableau ground truth to read, Sigma's flags features it can't mechanize instead of emitting a plausible-but-wrong formula. That shared honesty is the reason either one produces correct numbers.

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:

Retiring legacy BI with an agentic-built Streamlit app on SPCS
Build-vs-buy got rewritten by agentic coding. So I retired the legacy BI tool. 😎

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 againstWhen
Pre-built views (prefixed V_* objects in SQL)Whenever they exist. They already encode the joins and filters the workbook assumes.
Raw star schemaWhen the workbook uses LOD calcs or needs fields the views don't expose.
Semantic viewRarely. 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:

Two AI agents, one repo: delegating Snowflake work from Claude Code to Cortex Code
Claude Code and Snowflake’s Cortex Code turn out to be the same agent wearing different hats - and that’s exactly what makes them worth combining. A filesystem handoff between two AI agents replaced hours of manual Snowsight clicking.

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 --bypass

Validated .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.

  1. MONTH([Date]) collapses years. Tableau's MONTH() date-part buckets Jan-2024 and Jan-2025 into one "January" point (12 buckets across all years), while DATE_TRUNC('month', d) keeps the year (24 points across 2 years).
  2. Tableau's IF/ELSEIF ... ELSE catches NULL in the ELSE arm. A NULL routed through a chained test falls through to the last branch, because every NULL >= n is 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'
end

What "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).

GitHub - twells89/sigma-migration-skills: Claude Code plugin marketplace: migrate Tableau / Power BI / Qlik to Sigma (converter + assessment per tool). Validated with warehouse parity.
Claude Code plugin marketplace: migrate Tableau / Power BI / Qlik to Sigma (converter + assessment per tool). Validated with warehouse parity. - twells89/sigma-migration-skills

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:

  1. Discover: pull the source model plus sheets/dashboards.
  2. Translate: map calcs/LODs/table calcs to Sigma formulas using a converter plus a gap‑scout subagent.
  3. Data model: build the Sigma data model (tables, relationships, metrics), reconciled to the warehouse.
  4. Workbook: rebuild pages/visuals as a Sigma workbook, with layout applied last.
  5. 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 .twb is 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.yaml and 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:

Dimensionbuild-dashboard-page (Streamlit)sigma-migration-skills (Sigma)
Where it landsCode in my repo, my infra (SPCS)Governed object on a vendor platform (Sigma)
How it handles SQL / formulasDelegate SQL to CoCo, validatedTranslate to Sigma formulas, gap-scouted
How it handles window calcs / LODsHandled in SQL, no special machinerySigma-native window math + LOD helpers
How it verificiesPer-query validation + render smoke testWarehouse parity (value + PNG) per chart
Ground truthThe Tableau workbookThe 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. 😎