# TS Object Model Erd

> Render an existing ThoughtSpot Model (Model TML + its Table TMLs) into an interactive, self-contained HTML ERD — tables, joins, columns, findings and row-level security — that opens in any browser and is shareable without a ThoughtSpot login. Use when someone wants to chart, visualise, diagram, or review the structure of a Model. Not for editing models or generating TML.

- Skill: `thoughtspot/ts-object-model-erd` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add thoughtspot/ts-object-model-erd`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thoughtspot/ts-object-model-erd/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: thoughtspot (https://skillmd.com/u/thoughtspot)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/thoughtspot/ts-object-model-erd

---


# ThoughtSpot: Model ERD

Render a Model into a single self-contained HTML ERD. Read-only — never modifies the source model.

**When to use this skill:**

- You want to visualise or diagram the structure of a ThoughtSpot Model
- You need to review joins, cardinality, column layout, or RLS before making changes
- You want to share a Model's structure with someone who doesn't have ThoughtSpot access
- You need a visual reference while coaching or auditing a Model

**Relationship to other skills:**

| Need | Skill |
|---|---|
| Coach the model for Spotter readiness | `/ts-object-model-coach` |
| Audit the full environment | `/ts-audit` |
| Remove or repoint dependencies | `/ts-dependency-manager` |

Ask one question at a time for **dependent** decisions. Batch **independent** questions
into a single prompt to cut round-trips.

---

## References

| File | Purpose |
|---|---|
| [../../shared/schemas/thoughtspot-model-tml.md](../../shared/schemas/thoughtspot-model-tml.md) | Model TML structure |
| [../../shared/schemas/thoughtspot-table-tml.md](../../shared/schemas/thoughtspot-table-tml.md) | Table TML structure — joins_with, rls_rules |
| [../ts-profile-thoughtspot/SKILL.md](../ts-profile-thoughtspot/SKILL.md) | ThoughtSpot auth, profile config |

---

## Prerequisites

- Python 3.9+ with `pyyaml` (`pip install pyyaml`)
- For live export: a ThoughtSpot profile (`/ts-profile-thoughtspot`) + the `ts` CLI
- For a source semantic definition (Step 1.5): the `ts` CLI only — no ThoughtSpot
  profile and no warehouse connection are needed

---

## Step 0 — Overview (confirm before starting)

On skill invocation, display this plan before doing any work:

---
**ts-object-model-erd** — render a Model into a self-contained HTML ERD.
Read-only — never modifies the source model.

### Steps

  1.  Choose source ....................................... ask
  1.5 (Source definition) Convert offline, then render ... ask/auto
  2.  (Live) Authenticate + select models ................ ask/auto
  3.  Read or export TML .................................. auto
  4.  Synthesize AI-analysis corpus ...................... auto
  5.  Render ERD .......................................... auto
  6.  Open / share ........................................ done

### Options

| Flag | Default | Effect |
|---|---|---|
| `--ai-analysis` | off | Inject a synthesized business-context corpus (domain, objectives, audience, questions, AI instructions) into the ERD |
| `--redact-rls` | off | Replace RLS expressions with `(redacted)` for external sharing |
| `--max-models` | 25 | Cap on models per ERD file |
| `--out` | `model_erd.html` | Output path |

---

Confirm the user wants to proceed before starting Step 1.

---

## Step 1 — Choose source

Ask: **(A) Local TML files or folder**, **(B) Live ThoughtSpot instance**, or
**(C) A source semantic definition** (a Snowflake Semantic View or a Databricks Metric View)?

- **A — Files:** ask for the path(s) to the TML files or directory. Skip to Step 3.
- **B — Live:** proceed to Step 2.
- **C — Source definition:** proceed to Step 1.5.

---

## Step 1.5 — (Source definition only) Convert, then render

The ERD renders ThoughtSpot TML, so convert first. This runs **fully offline** — no
ThoughtSpot instance, no warehouse, no profile — because `--tables auto` derives the
alias→table map from the parse output instead of introspecting a warehouse.

Ask for the path to the source file, then run the pair of commands for its format
and continue to Step 5 with the emitted `.model.tml`.

**Snowflake Semantic View** (a `CREATE SEMANTIC VIEW` DDL file — get one with
`GET_DDL('SEMANTIC_VIEW', '<fqn>')` if the user only has the view name):

```bash
ts snowflake parse-sv "{ddl_file}" -o parsed.json
ts snowflake translate-formulas -i parsed.json -o translated.json
ts snowflake build-model -p parsed.json -t translated.json \
    --tables auto -n "{model_name}" -o tml_out --sv-fqn "{source_fqn}"
```

**Databricks Metric View** (the Metric View YAML):

```bash
ts databricks parse-mv "{mv_yaml}" -o parsed.json
ts databricks translate-formulas -i parsed.json -o translated.json --tables auto
ts databricks build-model -p parsed.json -t translated.json \
    --tables auto -n "{model_name}" -o tml_out --mv-fqn "{source_fqn}"
```

Note the asymmetry: Databricks needs `--tables auto` on **both** commands because its
expressions are dot-paths that resolve through the map, while Snowflake needs it only at
`build-model`.

**Report what the conversion says.** Both commands print a JSON summary and send
diagnostics to stderr. A `SKIPPED` line means a measure or dimension did not survive
translation and will be **absent from the diagram** — surface those to the user rather
than letting the ERD imply the source had nothing there. On the Databricks side a
`WARNING: '<path>' has no table FQN` means a `sql_query` source was labelled with its
alias, because a real conversion would build a SQL View there.

**Say what the diagram is.** An ERD built this way shows **the ThoughtSpot Model the
source would convert to**, not the source's native shape. For a Semantic View that is
close to 1:1; state it anyway when sharing the file. `--sv-fqn` / `--mv-fqn` writes the
source FQN into the model description, so the provenance travels with the diagram.

**Do not import this model.** `--tables auto` looks nothing up, so the map carries no
warehouse GUIDs; both commands refuse `--profile` for that reason. Converting **for
real** is a different job with different steps (table registration, connection choice,
review gates) — route the user to `/ts-convert-from-snowflake-sv` or
`/ts-convert-from-databricks-mv`.

Other sources (Tableau, Power BI, Qlik, Sisense, Looker) are **not** wired into this
step. Their conversion invents the table layer from extracts and makes reconcile and
name-mapping decisions, so a diagram-only run would stub out exactly the choices that
determine what the picture looks like. To diagram one of those, run its converter
properly first and render the resulting TML through option **(A)**.

---

## Step 2 — (Live only) Authenticate, enumerate, select

1. Read `~/.claude/thoughtspot-profiles.json`. If missing or empty, run `/ts-profile-thoughtspot`.
2. List Models:
   ```bash
   ts metadata search --subtype ONE_TO_ONE_LOGICAL --all --profile "{profile}"
   ```
3. Show the user the list. Let them pick one or more models. Confirm selection before export.

---

## Step 3 — Read or export TML

- **Files path:** note the folder/paths provided in Step 1.
- **Live path:** for each selected model, export it **with its associated tables** and
  redirect the JSON straight to a file:
  ```bash
  ts tml export "{model_guid}" --fqn --associated --profile "{profile}" > export.json
  ```
  `--associated` pulls the Table TMLs needed for join cardinality, join origin, RLS rules,
  and join keys.

  `ts tml export` writes a **single JSON array** to stdout (one `{edoc}` object per exported
  object). Feed that file directly to the builder in Step 5 — **do not** hand-split it into
  per-object `.tml` files. `build_erd.py` ingests the export JSON as-is and routes each object
  to model/table by its TML content. Multiple models: pass several GUIDs to one `ts tml export`
  call, or pass several `export*.json` files to the builder.

---

## Step 4 — Synthesize the AI-analysis corpus (optional but recommended)

Models rarely define a business-context corpus (domain, objectives, audience, business
questions). Synthesize one by **reasoning over the model definition** — table and column
names, per-column `properties.ai_context`, `properties.synonyms`, measure aggregations,
formula expressions, joins, and RLS. This is a judgment task for you (the agent); do not
try to derive it with a heuristic script.

Write the result to a sidecar JSON keyed by model **guid** (or name):

```json
{
  "<model-guid>": {
    "ai_analysis": {
      "domain": "one-paragraph description of the business domain the model serves",
      "objectives": ["what analysis it's built for", "..."],
      "personas": ["who uses it", "..."],
      "questions": ["representative business questions it answers", "..."]
    },
    "ai_instructions": ["semantic rules grounded in the model, e.g. 'Amount is the primary revenue measure'", "..."]
  }
}
```

Ground every entry in the actual model: cite real measures, note semi-additive or averaged
aggregations, and lift genuine guidance out of `ai_context` (e.g. day-grain date handling).
This step is **read-only** — the corpus enriches the ERD only and is never written back to
the source model.

---

## Step 5 — Render

Run — pass the Step 3 `export.json` (or a directory / individual `.tml` files) directly:

```bash
python3 agents/cli/ts-object-model-erd/build_erd.py export.json --out model_erd.html
```

`src` accepts, in any mix: a `ts tml export` JSON dump, individual `.tml`/`.yaml` files, or a
directory of them. Model vs. table is decided by TML content, so no naming convention or split
step is required.

Add `--ai-analysis <corpus.json>` to inject the Step 4 corpus into the ERD's Model domain /
Key objectives / Audience / Business questions / AI instructions sections.
Add `--redact-rls` if the user wants to hide RLS expressions for external sharing.
Add `--max-models N` to change the model cap.

If the source contains **no model** (e.g. only table TMLs), the builder exits non-zero with a
clear message rather than writing an empty diagram — re-export with `--associated` and confirm
the model GUID.

Report any degraded-fidelity or model-cap log lines to the user. Degraded fidelity means
Table TMLs were missing for some joins — the ERD still renders structure, but cardinality,
join type, join origin, RLS, and join keys are omitted for those joins.

---

## Step 6 — Open / share

Open `model_erd.html` in a browser. The file is fully self-contained — share the single
HTML file; no ThoughtSpot login required to view.

**ERD features:**
- Four layouts: Organic, Star, Layered →, Layered ↓
- Two notations: Arrow (ThoughtSpot-style), Crow's foot (cardinality)
- Column modes: Collapsed, Join keys, Flagged only, All columns
- Focus mode: click a table to highlight its neighbourhood; shift-click to compare two tables
- RLS overlay: toggle to see secured subgraph
- Findings overlay: toggle to see structural findings
- Drag to reposition tables; positions auto-save in localStorage
- Multi-model switcher (when multiple models rendered)
- Navigation: drag or scroll to pan; pinch / ⌘-scroll (Ctrl-scroll on Windows) to zoom;
  arrow keys pan, `+`/`-` zoom, `0` fits (to the focused neighbourhood if a table is
  focused, otherwise the whole model) — built for large models (verified on a 79-table
  export) where the default fit would otherwise be microscopic
- Minimap: always-on overview in the bottom-right corner with a viewport rectangle;
  click or drag it to jump anywhere in the model; collapsible, and the collapsed/expanded
  state persists across reloads
- Search zooms to a readable level (never leaves you at a microscopic fit scale)
- Semantic zoom (overview legibility): below 0.5× zoom, tables render as bold,
  color-coded overview blocks (solid saturated fill by kind, crisp non-scaling border,
  centered title) instead of full column cards — so a large model's fit view (e.g. 79
  tables at ~0.12×) still reads as distinct, outlined regions rather than faint white
  boxes. Node and edge borders no longer scale away to invisibility at any zoom level.
  Crosses back to full detail cards above the threshold; state borders (secured/RLS/
  severity/focus), click-to-focus, drag, and the minimap all keep working at either scale.
- Notes: attach a free-text note to any table or join from its inspector panel — Save
  and Delete give immediate feedback (a self-dismissing "Saved ✓" / "Note deleted"
  confirmation, with Delete reachable in the same session, no reload needed). A **Notes**
  filter chip highlights every noted object — including at overview/LOD zoom, where the
  note badge itself isn't drawn — and disables itself automatically when no notes exist
  (enables the instant a note is saved, including notes baked into a shared HTML export).
  A **Review notes** button lists every note at once (table/join tag, or "not in model"
  for a note whose object was since renamed or removed); clicking a row jumps to and
  centers that object, and each row can be deleted inline.
- Group by (subject areas): a **Group by** selector colors tables into subject areas via
  one of three selectable strategies — **Name prefix** (source-system naming, e.g.
  `SFDC_*`/`MIXPANEL_*`), **Graph cluster** (community detection on the join graph, for
  models whose naming is messy but whose joins reflect domains), or **Fact neighbourhood**
  (each fact + the dimensions nearest to it). A group shows as a colored accent stripe on
  each node (and tints the overview/LOD block), with a legend you can click to highlight a
  single group. Non-destructive — grouping only recolors; it never changes the layout, and
  composes with the fact/dim fill and RLS/focus borders. Different models suit different
  strategies; pick the one that reads best.

---

## Notes

- RLS rules, join cardinality, join type, and join origin come from the **Table** TMLs.
  Without them the ERD still renders structure, and the run logs what was omitted.
- Layout positions auto-save in the browser's localStorage per model.
- The HTML file has no external dependencies — no CDN, no network requests.

---

## Changelog

| Version | Date | Summary |
|---|---|---|
| 1.8.0 | 2026-08-05 | **BL-205 — render an ERD from a source semantic definition.** New Step 1 option **(C)**, covering a Snowflake Semantic View and a Databricks Metric View, with a Step 1.5 that converts offline and hands the emitted Model TML to the existing render. The conversion always worked without a ThoughtSpot instance; what blocked it was that both `build-model`s demand warehouse-registration inputs a diagram never uses, so producing one meant hand-deriving a tables map. `--tables auto` (ts-cli 0.129.0) closes that, and on Databricks `--connection` is no longer required alongside it. Scoped to those two formats deliberately: they are declarative semantic-layer objects, while the BI-workbook converters invent the table layer and would stub the decisions that shape the diagram — those still render via option (A) after a real conversion. The step also requires reporting `SKIPPED` translations (absent from the diagram, so silence would misrepresent the source) and stating that the ERD shows the ThoughtSpot Model the source converts to. |
| 1.7.3 | 2026-08-05 | **Column descriptions never reached the ERD.** `_column_entry` read `desc` from a column's **top-level** `description` only — the field its own docstring notes is "usually null" — while falling back to `properties` for `synonyms` and `ai_context`. Model TML puts a column comment under `properties.description`, so every description a conversion skill emits was dropped and the column inspector rendered blank: a Snowflake Semantic View converted with `/ts-convert-from-snowflake-sv` carries a `comment=` on each dimension, metric and fact, and all 60 of them were lost on a 13-table support-case model. `description` now takes the same `properties` fallback as its two neighbours, with the top-level field still winning when an export carries both. |
| 1.7.2 | 2026-07-31 | **BL-203 — an inline join's real name was discarded, hiding same-pair joins and faking a fidelity warning.** `_build_joins` synthesized every inline join's name as `{from}_{to}`, throwing away the model TML's `name:`. Two consequences, both found when a skill operator noticed `FACT_OPEN_ORDERS`'s `BOOKED_DATE_ID` and `ORDER_DATE_ID` joins missing from a rendered ERD. (a) The viewer keys edges by name (`showEdge(name)`), so where a table pair is joined more than once only the **first** was reachable in the inspector — on a real 44-join model that hid **18 of 23** date joins, and the overlapping edges drew as one line. (b) `_log_degraded_fidelity` looked the synthesized name up in the Table-TML join index, where it can never match, so **every inline-join model** got a false "Fidelity degraded: N join(s) had no Table TML definition" naming joins whose cardinality and type were in fact known — read straight off the inline join. Now prefers the declared name, and the warning fires only for joins that genuinely still lack cardinality/type (i.e. `referencing_join` ones), including in the no-Table-TMLs-at-all branch. |
| 1.7.1 | 2026-07-22 | Relax prompt-batching: allow independent questions in a single prompt (BL-074) |
| 1.7.0 | 2026-07-03 | **Subject-area grouping.** A new **Group by** selector colors tables into subject areas via three runtime-selectable strategies — **Name prefix** (leading name token, skipping modifier prefixes like `W_`/`DIM_`, with a >50%-Other fallback), **Graph cluster** (deterministic Louvain-style modularity community detection on the de-duplicated join graph — chosen over label propagation, which either floods a hub-centric model into one community or fails to bootstrap; on the 79-table GTM export it yields ~20 communities), and **Fact neighbourhood** (each non-fact node assigned to its nearest fact via a deterministic `(dist,factId)` relaxation). Strategy is pluggable; the display is shared and non-destructive: a colored accent stripe per node (inset so it never overpaints the state border), a group tint on the overview/LOD block (dark AA-contrast variant behind the white title), and a legend (ordered by size) whose rows highlight a single group as a **ghost-only** overlay that never hides nodes and clears on mode change / model load / filter or empty-canvas click. Grouping recolors only — layout, fact/dim fill, and RLS/focus borders are untouched; the minimap keeps kind fills. Also hardens `esc()` to escape quotes (and fixes an unescaped `data-target` in `findingCard`), and refreshes the Review-notes panel in place after "Clear notes". |
| 1.6.0 | 2026-07-03 | **Notes UX.** Fixes four reported gaps in object notes. (1) Save/delete now give immediate feedback: a shared `commitNotes()` exit (persist → sync → redraw) backs every note write, `wireNotes(id,isEdge)` re-renders the inspector after save/delete — so Delete is reachable without a reload — and a transient "Saved ✓" / "Note deleted" confirmation appears (scroll-preserving, reduced-motion aware). (2) A new **Notes** filter chip highlights every noted table, including at overview/LOD zoom where the `✎` badge isn't drawn; a noted join stays emphasized under the filter even when neither endpoint table has a note. The chip enables/disables correctly on model load — including when notes are baked into a Share-HTML export — and never leaves the diagram ghosted behind a disabled chip after the last note is removed. (3) A new **Review notes** panel lists every note with its object type (or "not in model" for a stale key whose object was renamed/removed since — rendered delete-only, never clickable); clicking a table/join row jumps to and centers it on the canvas, and each row deletes inline. (4) Added a defensive missing-id guard to `showTable` (mirrors `showEdge`'s). No change to note storage, keying, or Share-HTML baking. |
| 1.5.0 | 2026-07-02 | **Overview legibility (semantic zoom).** On large models the fit view (e.g. GTM's 79 tables at ~0.12×) previously rendered borders that scaled away to sub-pixel invisibility and pale card fills nearly identical to the canvas ground — tables read as faint, outline-less rectangles. Node body/edge strokes (including the transparent edge hit-path, so edges stay clickable at overview zoom) now use `vector-effect:non-scaling-stroke` so a border stays ~its nominal px at any zoom. Below a new 0.5× threshold, nodes render as bold overview blocks — solid saturated kind fill (fact deep blue `#1B5E8C`, dim dark slate `#5B6472`, sql_view dark teal `#0F766E`, each ≥4.5:1 WCAG AA for white title text), the same state-aware border (secured/RLS/severity/focus) as the detailed card, and a centered title only — instead of the full column card; crossing back above the threshold restores full detail. Re-render fires only on the threshold crossing, never per zoom step or on pan. Click-to-focus, drag, and the minimap keep working unchanged at either scale. No layout, position, or minimap change; small models that fit above the threshold (e.g. mini) are unaffected. |
| 1.4.1 | 2026-07-02 | **Correct the unresolvable-join handling.** A join whose `with:` target isn't a table in the model cannot occur in a valid ThoughtSpot export (the model editor won't create one and TML import validates join targets), so the 1.4.0 approach of defensively skipping such joins in the viewer was guarding an impossible state. The parser now **drops** any join with a non-existent endpoint at the source (and logs it as malformed TML), so the viewer no longer needs — and no longer carries — the `adj`/`undir`/edge dangling-endpoint guards. This also makes the join count honest (a dropped join is no longer counted-but-undrawn). No change for valid models (verified 0 dropped on a 79-table model); only genuinely malformed TML is affected. |
| 1.4.0 | 2026-07-02 | **Large-model navigation.** Scroll/trackpad now pans the canvas (pinch or ⌘/Ctrl-scroll zooms, cursor-anchored) instead of always zooming; a pan-drag no longer wipes focus/selection on release. Arrow keys pan, `+`/`-` zoom, `0` fits. New always-on **minimap** (bottom-right, collapsible, state persisted to localStorage, Share-HTML safe) shows the whole model with a live viewport rectangle; click or drag it to jump anywhere. `fit()` now has a shared `MIN_K=0.12` zoom floor (was an unbounded 0.047× on a 79-table model) used consistently by fit, wheel-zoom and `#zoom-out`; `#zoom-fit`/`0` fit to the focused neighbourhood instead of clearing focus when something is focused; search now zooms to a readable level (≥0.9×) before centering. Fixed a latent crash: a join referencing a table with no Table TML and no `model_tables` entry (a "degraded fidelity" join) previously threw in the browser (`adj[...]`/`undir[...]` on an unknown id) instead of just being logged and skipped. |
| 1.3.0 | 2026-07-02 | **Ingest:** `build_erd.py` now ingests a `ts tml export` JSON dump directly (raw `{edoc}` list or `--parse` form) and routes each object to model/table by TML **content**, not filename — the SKILL.md Step 3 → 5 flow is now a clean pipe with no manual split (the previous flow silently rendered an empty diagram when export JSON was hand-split into wrongly-suffixed files). Builder now **exits non-zero with a clear message** when the source contains no model, instead of writing an empty HTML. Existing `.model.tml`/`.table.tml` files and directories still work unchanged. **RLS model corrected:** RLS is no longer modelled as propagating along join edges. A table is highlighted only when a rule is **defined on it** (secured, red) or when it is **referenced in another table's rule expression** (in RLS path, amber). Removed the incorrect "RLS inherited / constrained via joins" pills, tooltips, rule-card "propagates through joins" line, dashed-red "RLS edge", and the join-ancestor subgraph; rule cards now list the other tables a rule references. **Column inspector fix:** column groups (Join keys / Measures / Attributes / Formulas) are now flat headed sections instead of `<details>` nested inside the Columns `<details>` — nested disclosure left the column rows hidden in some browsers (the section header showed a count but expanding revealed nothing). |
| 1.2.0 | 2026-07-02 | New `--ai-analysis` flag injects an agent-synthesized business-context corpus (domain, objectives, audience, business questions, AI instructions) into the ERD (read-only; never written back). Column inspector now surfaces per-column AI context and synonyms. RLS legend parity: secured tables show the red border + 🔒 by default (no longer gated behind the RLS overlay) and the "In RLS path" fill matches the legend swatch. Focus: double-click (join subtree) and shift-click compare now hide out-of-scope tables instead of dimming them. Parser fix: handle the nested `rls_rules: {rules: […]}` TML shape (previously crashed) |
| 1.1.1 | 2026-07-02 | Classifier: a table is a fact only when it has real (visible) measures — an outgoing join alone no longer makes a dimension a fact (e.g. a user/lookup table that joins onward); measureless pass-through tables are bridges. ERD viewer: clicking a flagged fan-out join no longer errors when no fan-out finding is attached (shows a generic fan-out explanation) |
| 1.1.0 | 2026-07-02 | Layered layout clusters joined tables (Sugiyama median crossing-reduction); fix dimension/fact classifier (hidden and non-measure formula columns no longer promote a dimension to a fact); ERD parser + assembler moved to the shared `erd` library so the skill and the ts-audit ERD embed share one definition |
| 1.0.0 | 2026-07-01 | Initial release |

