> ## Content Index
> Fetch the complete content index at: https://www.sfrt.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Migrating Tableau dashboards: Streamlit code vs. Sigma with a parity gate
- URL: https://www.sfrt.io/migrating-tableau-dashboards-streamlit-code-vs-sigma-with-a-parity-gate/
- Published: 2026-06-18T06:50:35.000Z
- Updated: 2026-06-18T06:50:35.000Z
- Description: Same migration, opposite bets: rebuild as Streamlit code you own, or as Sigma workbooks behind a parity gate.
- Author: Martin Seifert
- Tags: Visualization

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](https://www.linkedin.com/feed/update/urn:li:activity:7472665253789745152?ref=sfrt.io) upon newly open-sourced marketplace skills (by [TJ Wells](https://www.linkedin.com/in/thomas-j-tj-wells-8340729?ref=sfrt.io)) that migrate Tableau (and seven other BI tools) to [Sigma](https://www.sigmacomputing.com/?ref=sfrt.io). 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 SPCSBuild-vs-buy got rewritten by agentic coding. So I retired the legacy BI tool. 😎![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/icon/headshot_ring-d8eeaa1f-7443-4309-b730-5446638e7c29.png)sfrtMartin Seifert![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/thumbnail/generated-image--5--65d0c885-f183-4676-b5e6-c47ba09d3035.png)](https://www.sfrt.io/retiring-legacy-bi-with-an-agentic-built-streamlit-app-on-spcs/)

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:

[Two AI agents, one repo: delegating Snowflake work from Claude Code to Cortex CodeClaude 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.![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/icon/headshot_ring-91a2fb24-cf9f-4a01-b805-a3161dc6ec2c.png)sfrtMartin Seifert![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/thumbnail/claude_and_coco-2aad03f6-e096-4bab-a25f-a9f986286f88.png)](https://www.sfrt.io/two-ai-agents-one-repo-delegating-snowflake-work-from-claude-code-to-cortex-code/)

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:

```bash
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:

```python
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:

```sql
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![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/icon/pinned-octocat-093da3e6fa40-5fbeb611-9474-44a1-8cf0-1cf05b306bfa.svg)GitHubtwells89![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/thumbnail/sigma-migration-skills-268005f4-f642-4c50-b592-d768859df938)](https://github.com/twells89/sigma-migration-skills?ref=sfrt.io)

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:

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