> ## Documentation Index
> Fetch the complete documentation index at: https://docs.omni.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating Looker dashboards to Omni

> Programmatically migrate Looker dashboards to Omni with Python, agent skills, and the Omni CLIs.

export const categoryIcons = {
  'administration': 'lock',
  'api': 'terminal',
  'connections': 'database',
  'dashboards': 'table-columns',
  'embed': 'code',
  'errors': 'exclamation',
  'migration': 'angles-right',
  'modeling': 'wrench',
  'patterns': 'plus',
  'schedules & alerts': 'envelope',
  'visualizations': 'chart-column',
  'workbooks': 'book'
};

export const GuideSidebar = ({category, relatedLinks, updatedDate}) => {
  const [progress, setProgress] = React.useState(0);
  React.useEffect(() => {
    const sidebar = document.querySelector('.guide-sidebar');
    if (!sidebar) return;
    let container = sidebar.parentElement;
    while (container && !container.querySelector('.guide-header')) {
      container = container.parentElement;
    }
    if (container && !container.classList.contains('guide-page-layout')) {
      container.classList.add('guide-page-layout');
    }
  }, []);
  React.useEffect(() => {
    const handleScroll = () => {
      const scrollTop = window.scrollY;
      const docHeight = document.documentElement.scrollHeight - window.innerHeight;
      const scrollPercent = docHeight > 0 ? scrollTop / docHeight * 100 : 0;
      setProgress(Math.min(100, Math.max(0, scrollPercent)));
    };
    window.addEventListener('scroll', handleScroll, {
      passive: true
    });
    handleScroll();
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  const icon = category ? categoryIcons[category.toLowerCase()] || 'book' : 'book';
  return <aside className="guide-sidebar">
      <div className="guide-sidebar-content">
        <a href="/guides" className="guide-sidebar-back">
          <Icon icon="arrow-left" iconType="solid" size={14} />
          <span>All guides</span>
        </a>

        <div className="guide-sidebar-section">
          <div className="guide-sidebar-label">Progress</div>
          <div className="guide-sidebar-progress">
            <div className="guide-mascot">
              <svg viewBox="0 0 688 690" width="48" height="48">
                <defs>
                  <clipPath id="progressClip">
                    <rect x="0" y={0} width="688" height={progress * 6.9} />
                  </clipPath>
                </defs>

                {}
                <path d="M343.67 1.5C542.684 1.5 685.84 149.351 685.84 344.84C685.84 540.328 542.685 688.18 343.67 688.18C144.655 688.18 1.5 540.318 1.5 344.84C1.50007 149.361 144.655 1.50005 343.67 1.5Z" fill="#FCFCF7" stroke="#FF5FA2" strokeWidth="3" />

                {}
                <path d="M343.67 0C143.81 0 0 148.55 0 344.84C0 541.13 143.81 689.68 343.67 689.68C543.53 689.68 687.34 541.14 687.34 344.84C687.34 148.54 543.53 0 343.67 0Z" fill="#FF5FA2" clipPath="url(#progressClip)" />

                {}
                <path d="M337.89 319.29C337.89 336.75 322.49 350.14 302.81 349.83C286.18 349.57 273.89 337.29 274.37 321.45C274.88 304.82 290.91 290.88 309.98 290.44C325.69 290.09 337.88 302.69 337.88 319.29H337.89Z" fill="#4D122C" />
                <path d="M566.17 319.29C566.17 336.75 550.77 350.14 531.09 349.83C514.46 349.57 502.17 337.29 502.65 321.45C503.16 304.82 519.19 290.88 538.26 290.44C553.97 290.09 566.16 302.69 566.16 319.29H566.17Z" fill="#4D122C" />
                <path d="M367.74 342.07C360.22 346.32 359.4 354.9 370.62 366.4C381.85 377.9 399.76 389.56 420.81 389.18C441.88 389.1 460.67 377.72 472.47 363.53C473.83 361.93 478.84 356.88 478.51 351.07C478.32 348.35 476.17 341.19 467.83 341.38C463.46 341.44 461.21 343.68 456.69 347.36C445.2 356.14 432.7 361.21 420.56 361.27C408.43 361.43 395.68 356.17 385.39 347.22C380.32 342.81 375.25 337.82 367.74 342.07Z" fill="#4D122C" />
              </svg>
            </div>
            <span className="guide-sidebar-progress-text">{Math.round(progress)}%</span>
          </div>
        </div>

        {category && <div className="guide-sidebar-section">
            <div className="guide-sidebar-label">Category</div>
            <div className="guide-sidebar-category">
              <Icon icon={icon} iconType="solid" size={14} />
              <span>{category}</span>
            </div>
          </div>}

        {updatedDate && <div className="guide-sidebar-section">
            <div className="guide-sidebar-label">Last updated</div>
            <div className="guide-sidebar-date">{updatedDate}</div>
          </div>}

        {relatedLinks && relatedLinks.length > 0 && <div className="guide-sidebar-section">
            <div className="guide-sidebar-label">Related</div>
            <ul className="guide-sidebar-links">
              {relatedLinks.map((link, index) => <li key={index}>
                  <a href={link.href}>{link.title}</a>
                </li>)}
            </ul>
          </div>}
      </div>
    </aside>;
};

export const GuideTitle = ({title}) => {
  return <div className="guide-header">
      <h1 className="guide-title">{title}</h1>
    </div>;
};

<GuideSidebar
  categoryIcons={categoryIcons}
  category="migration"
  updatedDate="June 2026"
  relatedLinks={[
{ title: "Local development with the model editor CLI", href: "/guides/modeling/local-development" },
{ title: "Agent skills", href: "/developers/agent-skills" },
{ title: "Omni CLI reference", href: "/developers/cli" },
{ title: "Git integration", href: "/integrations/git" },
{ title: "Topics", href: "/docs/model-data/curate-data-with-topics" },
{ title: "Branch mode", href: "/content/develop/branch-mode" },
]}
/>

<GuideTitle title="Migrating Looker dashboards to Omni" />

This guide covers how to migrate Looker dashboards to Omni using a scripted API approach — a Python builder script, the Omni CLI, `omni-sync`, and Claude Code with custom agent skills. Rather than rebuilding dashboards by hand, the toolset lets you inspect source dashboards programmatically, translate fields and filters, and create Omni dashboards via the API in a workflow that gets faster with every dashboard you migrate.

This is especially relevant if you:

* Are migrating multiple Looker dashboards and want a repeatable, scriptable approach
* Are comfortable working in Claude Code or Cursor and want to run most of the migration autonomously with agent skills
* Need to handle complex Looker patterns — dynamic fields, pivots, `parameter` fields — that don't map directly to Omni

## How the tools fit together

The migration involves six tools. Knowing what each one does before you start saves a lot of back-and-forth later.

<Steps>
  <Step title="Looker API" icon="code" id="looker-api" titleSize="h3">
    The Looker API is the starting point for every dashboard. The `dashboard_dashboard_elements` and `dashboard_dashboard_filters` endpoints return everything you need: fields, filters, sorts, pivots, dynamic fields, layout position, and filter-listen wiring. You can call these directly using the inspect script below, or let the agent skill handle it.
  </Step>

  <Step title="Omni CLI" icon="terminal" id="omni-cli" titleSize="h3">
    The Omni CLI (`@omni-co/cli`) handles branching, field management, model validation, and more. It's what the `omni-model-builder` agent skill calls under the hood. You can also use it directly for one-off operations.
  </Step>

  <Step title="omni-sync (@omni-co/model-local-editor)" icon="rotate" id="omni-sync" titleSize="h3">
    The `omni-sync` (`@omni-co/model-local-editor`) package is your live feedback loop for model changes. It watches local YAML files, pushes every save to your Omni branch, and streams validation errors back to the terminal in real time.

    Keep it running in a background terminal throughout the migration — it's what lets you (or Claude Code) iterate on the model without leaving the terminal.
  </Step>

  <Step title="omni-model-builder agent skill" icon="hammer" id="builder-skill" titleSize="h3">
    The `omni-model-builder` agent skill is a Claude Code / Cursor task definition from the [Omni agent skills repo](https://github.com/exploreomni/omni-agent-skills).

    Give it an instruction like *"add this missing dimension to the orders view"* and it handles the YAML edits, pushes via the CLI, reads the `omni-sync` validation output, and iterates until the model is clean.
  </Step>

  <Step title="omni_dashboard_builder.py Python script" icon="python" id="python-script" titleSize="h3">
    The `omni_dashboard_builder.py` file is the Python script that creates Omni dashboards via the API. You write a `build_<name>()` function per dashboard that assembles tiles from queries, register it in a `DASHBOARDS` dict, and run the script. It handles the API mechanics that would otherwise be tedious: layout via the export/import cycle, filter wiring, and visualization configs that the direct create API drops.

    See the [Appendix](#omni-dashboard-builder-py) for the full file contents.
  </Step>

  <Step title="looker-to-omni-dashboard agent skill" icon="forward" id="looker-skill" titleSize="h3">
    The `looker-to-omni-dashboard` agent skill orchestrates the full migration workflow in Claude Code. You give it a Looker dashboard ID and then it:

    * Calls the Looker API
    * Maps fields to Omni equivalents
    * Writes the `build_<name>()` function
    * Wires filters and layout
    * Runs the builder script
    * Opens the result URL for you to inspect

    See the [Appendix](#looker-to-omni-dashboard-skill-md) for the full file contents.
  </Step>
</Steps>

## Part 1: Get set up

The first step is to install the required packages and configure your tooling. You'll need to complete all of the steps in this section to follow the remaining sections in this guide.

<Steps>
  <Step title="Install Python 3.9+" titleSize="h3">
    You'll need Python 3.9 or later to follow the steps in this guide. See the [official Python downloads](https://www.python.org/downloads/) to download the correct version for your operating system.

    You can check what version of Python is installed, if any, by running:

    ```bash theme={null}
    python --version
    python3 --version
    python -V
    ```

    If the first command returns an error or a legacy version, try `python3`. Some systems explicitly require `python3`.
  </Step>

  <Step title="Install the Looker SDK" titleSize="h3">
    Install the [Looker SDK](https://pypi.org/project/looker-sdk/) by running:

    ```bash theme={null}
    pip install looker-sdk requests
    ```
  </Step>

  <Step title="Install Omni Agent Skills" titleSize="h3">
    1. Use the [Omni agent skills installation guide](/developers/agent-skills/install) to install the official plugin for Claude Code.
    2. Copy the [`looker-to-omni-dashboard-skill.md`](#looker-to-omni-dashboard-skill-md) file from this guide's appendix into `.claude/agents/` in your repo.

           <Note>
             The migration skill is distinct from the official plugin and needs to be placed there manually.
           </Note>
    3. Save the file as `looker-to-omni-dashboard.md`.
  </Step>

  <Step title="Install the Omni CLI" titleSize="h3">
    1. Use the [CLI installation guide](/developers/cli/install) to install the CLI.
    2. After installing, run `omni config init` once to configure your profile and set up authentication.
  </Step>

  <Step title="Install the model editor CLI (omni-sync)" titleSize="h3">
    <Note>
      See the [Local development with the model editor CLI guide](/guides/modeling/local-development) for more information about this CLI.
    </Note>

    Install the `@omni-co/model-local-editor` package globally:

    ```bash theme={null}
    npm install -g @omni-co/model-local-editor
    ```
  </Step>

  <Step title="Set environment variables" titleSize="h3">
    Set the following in the shell you'll run the migration scripts from:

    ```bash theme={null}
    export LOOKERSDK_BASE_URL="https://<your-instance>.looker.com"
    export LOOKERSDK_CLIENT_ID="<your-looker-client-id>"
    export LOOKERSDK_CLIENT_SECRET="<your-looker-client-secret>"
    export OMNI_BASE_URL="<your-omni-instance-url>"
    ```

    `looker_dashboard_inspect.py` and `omni_dashboard_builder.py` (both in the [Appendix](#companion-files)) read these directly — set them before running either script.
  </Step>

  <Step title="Start omni-sync" titleSize="h3">
    Before making any model changes, start a migration branch and keep the watcher running in a dedicated terminal throughout:

    ```bash theme={null}
    omni-sync init <your_model_id> --branch looker-migration --create-branch
    omni-sync start <your_model_id>
    ```
  </Step>
</Steps>

## Part 2: Configure Omni Git settings

1. Open your model in Omni by navigating to the model IDE.
2. Click **Model > Git Settings**.
3. Verify the following settings are enabled:

   * **Pull request required** — Prevents direct promotion of branch changes to the shared model
   * **Always create branches** — Ensures PRs created outside Omni automatically create a corresponding Omni branch

Open pull requests through the Omni web UI, not directly in GitHub. In the Omni IDE, click **Open Pull Request** in the branch selector.

<Warning>
  Don't push branches or pull requests directly to GitHub. Changes pushed outside of Omni won't be properly validated and may break the model.
</Warning>

## Part 3: The migration workflow

This approach works best when you start with your simplest dashboards. The first few teach you the failure modes specific to your Looker model, such as field prefix conventions, which dynamic fields translate cleanly, and how your filter patterns map to Omni. By the time you get to complex dashboards with 50+ tiles and intricate filter wiring, you'll have the playbook down and they'll go much faster.

<Steps>
  <Step title="Inspect the source dashboard" titleSize="h3">
    Run the [Looker inspect script](#looker-dashboard-inspect-py) against your Looker dashboard ID:

    ```bash theme={null}
    python3 looker_dashboard_inspect.py <dashboard_id>
    ```

    This calls `dashboard_dashboard_elements` and `dashboard_dashboard_filters` and prints a structured summary of every tile in visual order — fields, filters, sorts, pivots, dynamic fields, hidden fields, and filter-listen mappings — plus a ready-to-copy `tile_order` list at the end.

    If you're using Claude Code with the [`looker-to-omni-dashboard` skill](#looker-to-omni-dashboard-skill-md), this step runs automatically when you give it the dashboard ID. Read the output carefully before writing anything.

    See the [Appendix](#looker-dashboard-inspect-py) for the full Python script contents.
  </Step>

  <Step title="Verify and build missing model fields" titleSize="h3">
    Compare what the Looker dashboard requires against what's in your Omni model. For each tile, you need the topic to exist, every field to be present in the view YAML, and any joins to be in `relationships.yaml`. If anything's missing, add it before writing the build function.

    With `omni-sync` running, every YAML save validates immediately. With the `omni-model-builder` skill active in Claude Code, you can describe what's missing and let the agent handle the edits and validation cycle.
  </Step>

  <Step title="Write the build function and run the script" titleSize="h3">
    Add a `build_<name>()` function to [`omni_dashboard_builder.py`](#omni-dashboard-builder-py) using the `query()`, `tile()`, and visualization config helpers in the script, register it in `DASHBOARDS`, and run:

    ```bash theme={null}
    python3 scripts/omni_dashboard_builder.py
    ```

    `main()` builds every dashboard registered in `DASHBOARDS`. To build a subset, comment out the others. The script prints the dashboard URL when it finishes — open it immediately and check visually. Broken charts, missing filters, and wrong tile order are all obvious at a glance.

    See the [Appendix](#omni-dashboard-builder-py) for the script file, which includes all the details needed for writing build functions, translating visualizations, wiring filters, and handling edge cases.

    <Warning>
      The script calls `delete_existing_by_name()` before every create, which deletes every document accessible to your API token with that exact name across all folders. Use unique, script-managed names for dashboards built with this script and avoid reusing those names for anything built by hand.
    </Warning>

    <Note>
      Layout, filter wiring, and visualization configs are applied via an export/import cycle using `/api/unstable/` endpoints — confirmed still in beta as of this writing, per [Omni's API docs](/api/content-migration/export-dashboard). This is a field-tested workaround for limitations in the stable create API.

      Omni's newer v2 Documents API (`/v2/documents`) supports layout and dashboard filters natively via `containers` and `controls`. This script will be updated to the v2 create/patch/publish flow instead of the export/import round-trip. Until then, it's worth regression-testing after Omni upgrades if dashboard creation starts breaking.
    </Note>
  </Step>

  <Step title="Iterate" titleSize="h3">
    Fix what's wrong, rebuild, repeat. The script deletes and recreates cleanly on every run, so there's no stale state to manage.

    If you're in Claude Code, feed errors back to the agent and ask it to update the skill file, as that learning carries forward to every subsequent dashboard automatically.
  </Step>

  <Step title="Merge your migration branch" titleSize="h3">
    Once you've built and verified all the dashboards you're migrating, merge the branch you started in Part 1:

    ```bash theme={null}
    omni-sync merge-branch <your_model_id> looker-migration --delete-branch
    ```

    If **Pull request required** is enabled in your git settings (see [Part 2](#part-2-configure-omni-git-settings)), open the pull request through the Omni web UI first, then run the command above once it's merged to delete the local branch reference.

    If your environment doesn't require pull requests, skip the PR and merge directly with `--force-override-git-settings`:

    ```bash theme={null}
    omni-sync merge-branch <your_model_id> looker-migration --delete-branch --force-override-git-settings
    ```
  </Step>
</Steps>

## Part 4: Finish the cleanup in Omni

A few things can't be set programmatically and require manual work in the Omni UI after each dashboard is created:

* **KPI tile styling** — Font sizes and alignment must be set in the UI. The field and label persist through rebuilds, but visual styling does not.
* **Markdown and text tiles** — Not migratable; recreate manually
* **Chart color themes** — Must be applied in the UI after creation

<Note>
  Visualization configuration — axis assignment, series colors, chart formatting — has limited API coverage when building dashboards programmatically. Chart type hints set via the API may be overridden by Omni's automatic visualization logic, and detailed axis configs set through the UI won't be reproduced by the builder script.

  For the visual layer, use Omni's built-in AI assistant, accessible from the sidebar in any dashboard, to refine chart formatting after the dashboard structure is in place. Describe what each tile should look like and the Omni Agent will execute the changes.
</Note>

## Next steps

* [Set up local model development](/guides/modeling/local-development)
* [Explore the full Omni agent skills library](/developers/agent-skills)
* [Learn about branch-based model workflows](/content/develop/branch-mode)

<h2 id="companion-files">
  Appendix: Companion files
</h2>

The following files contain everything needed to run the migration. Click to open and copy the contents.

<AccordionGroup>
  <Accordion title="looker_dashboard_inspect.py" id="looker-dashboard-inspect-py" description="Looker dashboard inspector script">
    Run this first against your Looker dashboard ID to get a structured summary of every tile before writing any build functions.

    ```python expandable theme={null}
    """
    Looker Dashboard Inspector
    ==========================
    Fetches ground-truth metadata for a Looker dashboard and prints a structured
    summary of every tile — fields, filters, sorts, pivots, dynamic fields,
    hidden fields, layout position, and filter-listen mappings.

    Run this before writing any build function in omni_dashboard_builder.py.
    The output tells you exactly what each tile needs and in what visual order.

    Usage:
      python3 looker_dashboard_inspect.py <dashboard_id>

    Requirements:
      pip install looker-sdk

    Environment variables (set in shell before running):
      LOOKERSDK_BASE_URL       https://<your-instance>.looker.com
      LOOKERSDK_CLIENT_ID      your Looker API client ID
      LOOKERSDK_CLIENT_SECRET  your Looker API client secret
    """

    import json
    import os
    import sys

    try:
        import looker_sdk
        from looker_sdk import models40 as models
    except ImportError:
        raise SystemExit(
            "looker-sdk is required. Install it with: pip install looker-sdk"
        )

    # ── Config ────────────────────────────────────────────────────────────────────

    for var in ("LOOKERSDK_BASE_URL", "LOOKERSDK_CLIENT_ID", "LOOKERSDK_CLIENT_SECRET"):
        if not os.environ.get(var):
            raise SystemExit(
                f"Missing required environment variable: {var}\n"
                "Set LOOKERSDK_BASE_URL, LOOKERSDK_CLIENT_ID, and "
                "LOOKERSDK_CLIENT_SECRET before running."
            )

    # ── Helpers ───────────────────────────────────────────────────────────────────

    def _j(obj) -> str:
        """Pretty-print a value as compact JSON."""
        return json.dumps(obj, default=str)


    def _section(title: str):
        print(f"\n{'─' * 60}")
        print(f"  {title}")
        print('─' * 60)


    def inspect(dashboard_id: str):
        sdk = looker_sdk.init40()

        # ── Dashboard top-level ───────────────────────────────────────────────────
        dashboard = sdk.dashboard(dashboard_id)
        _section(f"Dashboard: {dashboard.title}  (id={dashboard_id})")
        print(f"  folder:      {getattr(dashboard.folder, 'name', None)}")

        # ── Dashboard-level filters ───────────────────────────────────────────────
        filters = sdk.dashboard_dashboard_filters(dashboard_id)
        if filters:
            _section("Dashboard filters")
            for f in filters:
                print(f"\n  [{f.name}]")
                print(f"    title:         {f.title}")
                print(f"    type:          {f.type}")
                print(f"    dimension:     {f.dimension}")
                print(f"    explore:       {f.explore}  model: {f.model}")
                print(f"    default_value: {f.default_value!r}")
        else:
            print("\n  (no dashboard-level filters)")

        # ── Tiles ─────────────────────────────────────────────────────────────────
        elements = sdk.dashboard_dashboard_elements(dashboard_id)

        # Sort by visual layout order: top-to-bottom, left-to-right
        elements = sorted(elements, key=lambda e: (e.row or 0, e.column or 0))

        _section(f"Tiles ({len(elements)} total, sorted by layout position)")

        for el in elements:
            # Skip text/markdown tiles — no Omni equivalent
            if el.type == "text":
                print(f"\n  ── [TEXT TILE — skip] title={el.title!r}")
                continue

            print(f"\n  ══ {el.title!r}")
            print(f"     type:     {el.type}")
            print(f"     layout:   row={el.row}  col={el.column}  w={el.width}  h={el.height}")

            q = None
            if el.result_maker and el.result_maker.query:
                q = el.result_maker.query
            elif el.query:
                q = el.query

            if q:
                print(f"\n     model/explore:  {q.model}.{q.view}")
                print(f"     fields:         {_j(q.fields)}")

                if q.filters:
                    print(f"     filters:")
                    for k, v in q.filters.items():
                        print(f"       {k}: {v!r}")

                if q.filter_expression:
                    print(f"     filter_expression: {q.filter_expression}")

                if q.sorts:
                    print(f"     sorts:          {_j(q.sorts)}")

                if q.pivots:
                    print(f"     pivots:         {_j(q.pivots)}")

                if q.hidden_fields:
                    print(f"     hidden_fields:  {_j(q.hidden_fields)}")
                    print(f"     ⚠  hidden_fields are computation-only — do NOT include in Omni tile fields")

                if q.dynamic_fields:
                    try:
                        dfs = json.loads(q.dynamic_fields)
                    except (TypeError, json.JSONDecodeError):
                        dfs = q.dynamic_fields
                    print(f"     dynamic_fields:")
                    for df in (dfs if isinstance(dfs, list) else [dfs]):
                        category = df.get("category", df.get("calculation_type", "unknown"))
                        label    = df.get("label", df.get("label_short", ""))
                        based_on = df.get("based_on", "")
                        df_filters = df.get("filters", {})
                        print(f"       category={category!r}  label={label!r}  based_on={based_on!r}")
                        if df_filters:
                            print(f"         filters: {_j(df_filters)}")

                if q.limit:
                    print(f"     limit:          {q.limit}")

                if q.vis_config:
                    vis_type = q.vis_config.get("type") if isinstance(q.vis_config, dict) else None
                    print(f"     vis_type:       {vis_type}")

            # Filter-listen mappings
            if el.result_maker and el.result_maker.filterables:
                for filterable in el.result_maker.filterables:
                    listens = [l.dashboard_filter_name for l in (filterable.listen or [])]
                    if listens:
                        print(f"     listens_to:     {listens}")

        # ── Summary: tile order ───────────────────────────────────────────────────
        _section("tile_order (copy into DASHBOARDS dict)")
        visible = [el for el in elements if el.type != "text"]
        print("\n  tile_order = [")
        for el in visible:
            print(f"      {el.title!r},")
        print("  ]")


    # ── Entry point ───────────────────────────────────────────────────────────────

    if __name__ == "__main__":
        if len(sys.argv) < 2:
            raise SystemExit("Usage: python3 looker_dashboard_inspect.py <dashboard_id>")
        inspect(sys.argv[1])
    ```
  </Accordion>

  <Accordion title="looker-to-omni-dashboard-skill.md" id="looker-to-omni-dashboard-skill-md" description="Claude Code / Cursor agent skill">
    <Note>
      Copy the full contents into `.claude/agents/looker-to-omni-dashboard.md` in your repo.
    </Note>

    ````markdown expandable theme={null}
    description: Migrate a Looker dashboard to Omni by adding a build function to scripts/omni_dashboard_builder.py. Covers the full workflow — Looker API inspection, field mapping, filter wiring, layout, and tile ordering. Use this whenever someone wants to migrate, rebuild, or recreate a Looker dashboard in Omni.
    argument-hint: "<looker_dashboard_id>"
    ---

    # /looker-to-omni-dashboard

    Migrate a Looker dashboard to Omni by adding a `build_<name>()` function to `scripts/omni_dashboard_builder.py`. Never write a standalone script. Never reconstruct field lists from manifest files.

    ---

    ## Configuration

    - Script: `scripts/omni_dashboard_builder.py`
    - Model ID: `<your-omni-model-id>`
    - Looker base URL: `$LOOKERSDK_BASE_URL` (already in shell — never source a file)
    - Omni base URL: `$OMNI_BASE_URL`

    ---

    ## Step 1 — Inspect the Looker Dashboard

    Always fetch ground-truth data from the Looker API. Run `looker_dashboard_inspect.py <dashboard_id>` to get every tile's fields, filters, sorts, pivots, dynamic fields, and filter-listen mappings sorted by visual layout order (row, column).

    Read the output carefully before writing any code. Note:
    - Tile titles (these become tile `name` args and drive `tile_order`)
    - Fields per tile (`model.explore` maps to Omni `view.field`)
    - Filter keys and values per tile
    - Which dashboard filters each tile listens to (`listens_to:` lines)
    - Any `dynamic_fields` — classify each one (see Step 1c below)
    - Any `hidden_fields` — fields in the query for computation only; do not include them in the Omni tile's `fields:` list
    - Any `pivots:` lines — must be translated (see Step 1d below)
    - Any `type == "text"` elements — skip them entirely (no Omni equivalent)

    ---

    ## Step 1b — Looker Parameters and Templated Filters

    Looker `parameter` fields map to Omni **templated filters**:

    ```yaml
    filters:
      my_filter_name:
        type: number
        label: My Filter
        suggest_from_field: users.user_id

    my_dimension:
      sql: |
        CASE
          WHEN {{# users.my_filter_name.filter }} field_name {{/ users.my_filter_name.filter }}
          THEN 'Matches'
          ELSE 'No Match'
        END
    ```

    Only Mustache block syntax (`{{# ... }}`) is supported. `{{ }}` and `{% %}` are not.

    **Multi-alias problem:** use the view's canonical `schema__table` name in the Mustache reference — not a join alias. An unset block collapses to `TRUE`, so OR-connected blocks make every row match.

    ---

    ## Step 1c — Classifying Dynamic Fields

    | `category` / `calculation_type` | What to do |
    |---|---|
    | `calculation_type: group_by` | Translate to a CASE WHEN dimension in the view |
    | `category: measure` — filters reference only fields in the same view | Translate to a view measure using `filters:` block |
    | `category: measure` — filters reference fields from other joined views | Build a PDT |
    | `category: table_calculation` — simple arithmetic between two native measures | Translate to a view measure |
    | `category: table_calculation` — uses `pivot_where()` as the primary metric | Translate whole tile to pre-aggregated measure + grouped bar chart |
    | `category: table_calculation` — references other table calcs or Looker runtime values | Skip |
    | `category: dimension` with raw LookML expression | Translate if CASE WHEN or string ops; skip if references `${measure}` fields |

    ---

    ## Step 1d — Translating Pivots

    - **Table tiles** — add `"pivots": ["view.field"]` to the query dict
    - **Chart tiles** — pivot is handled by the `vis_spec` color field; no explicit pivot needed

    A Looker chart pivot always maps to a stacked bar in Omni. Never use `_stack: "group"` for a chart that has `pivots:` in the inspect output.

    ---

    ## Step 2 — Verify Fields Exist in Omni

    1. Confirm the topic exists and its `base_view:` matches what you expect
    2. Confirm each `view.field` exists in the corresponding view YAML
    3. Confirm filter fields exist in the view YAML
    4. Confirm any joins are in `relationships.yaml` and in the topic's `joins:` block

    **Field prefix rules:** base view fields use `schema__table` format; joined view fields use the join alias as-is.

    If anything is missing, use the `omni-model-builder` skill to add it before writing the build function.

    ---

    ## Step 3 — Write the Build Function

    ```python
    def build_my_dashboard() -> list[dict]:
        tiles: list[dict] = []

        tiles.append(tile(
            "Total Orders", "1",
            query(view="analytics__orders", topic="orders", fields=["orders.order_count"]),
            config=cfg_kpi("orders.order_count", "Total Orders"),
        ))

        tiles.append(tile(
            "Revenue by Region", "2",
            query(
                view="analytics__orders", topic="orders",
                fields=["orders.region", "orders.total_revenue"],
                sorts=[sort_desc("orders.total_revenue")],
            ),
            vis_spec=cfg_bar(x_field="orders.region", series_fields=["orders.total_revenue"]),
        ))

        return tiles
    ```

    ### Visualization approach

    | Tile type | Use |
    |---|---|
    | KPI / single value | `config=cfg_kpi(field, label)` |
    | Stretch table | `config=cfg_table()` |
    | Line chart | `vis_spec=cfg_line(x_field, measure_field, color_field)` |
    | Bar / stacked bar | `vis_spec={...}` (see patterns below) |
    | Area chart | `chart_type="areaColor"` |

    **Stacked bar (use when inspect output shows `pivots:`):**

    ```python
    vis_spec={
        "x": {"field": {"name": "view.x_dim"}},
        "mark": {"type": "bar"},
        "color": {"field": {"name": "view.color_dim"}, "_stack": "stack", "legendPosition": "bottom"},
        "series": [{"field": {"name": "view.measure"}, "yAxis": "y", "mark": {"type": "bar"}, "color": {"_stack": "stack"}}],
        "version": 0, "behaviors": {"stackMultiMark": False},
        "configType": "cartesian", "_dependentAxis": "y",
    }
    ```

    **Grouped multi-measure bar (no pivot):**

    ```python
    vis_spec={
        "x": {"field": {"name": "view.x_dim"}},
        "y": {"color": {"_stack": "group"}}, "y2": {"color": {"_stack": "group"}},
        "mark": {"type": "bar"}, "color": {"_stack": "group"},
        "series": [
            {"field": {"name": "view.measure_1"}, "yAxis": "y", "mark": {"type": "bar"}, "color": {"_stack": "group"}},
            {"field": {"name": "view.measure_2"}, "yAxis": "y", "mark": {"type": "bar"}, "color": {"_stack": "group"}},
        ],
        "version": 0, "behaviors": {"stackMultiMark": False},
        "configType": "cartesian", "_dependentAxis": "y",
    }
    ```

    ---

    ## Step 4 — Define Layout

    Grid is 24 units wide. Use fixed heights: KPI = 23, table = 26, chart = 30.

    ```python
    MY_LAYOUT = [
        {"i": "1", "x":  0, "y":  0, "w":  8, "h": 23},
        {"i": "2", "x":  8, "y":  0, "w":  8, "h": 23},
        {"i": "3", "x":  0, "y": 23, "w": 24, "h": 30},
    ]
    ```

    ---

    ## Step 5 — Wire Dashboard Filters

    ```python
    MY_FILTER_CONFIG = {
        "date_filter": {
            "type": "date", "label": "Date range",
            "fieldName": "orders.created[date]",
            "kind": "TIME_FOR_INTERVAL_DURATION",
            "left_side": "6 months ago", "right_side": "6 months",
            "is_negative": False,
        },
    }
    MY_FILTER_ORDER = ["date_filter"]
    MY_TILE_FILTER_MAP = {
        "1": {"date_filter": "orders.created[date]"},
        "2": {"date_filter": False},
    }
    ```

    Every `fid` in `filterConfig` must appear in every tile's entry. Use `False` to exclude. Never use `[fiscal_quarter]` or `[quarter]` as filter keys — use `[date]` or `[month]`.

    ---

    ## Step 6 — Register in DASHBOARDS

    ```python
    DASHBOARDS = {
        "my-dash": {
            "name":            "My Dashboard",
            "build":           build_my_dashboard,
            "layout":          MY_LAYOUT,
            "folder":          "dashboards/folder",
            "filter_config":   MY_FILTER_CONFIG,
            "filter_order":    MY_FILTER_ORDER,
            "tile_filter_map": MY_TILE_FILTER_MAP,
            "tile_order":      ["Total Orders", "Revenue by Region"],
        },
    }
    ```

    ---

    ## Step 7 — Run It

    ```bash
    python3 scripts/omni_dashboard_builder.py
    ```

    `main()` builds every dashboard in `DASHBOARDS`. Comment out entries you don't want to run.

    ---

    ## Step 8 — Post-creation Manual Steps

    - **KPI tile styling** — font sizes and alignment must be set in the UI
    - **Markdown tiles** — not migratable; recreate manually
    - **Chart color themes** — set in the UI after creation

    ---

    ## Common Errors

    | Error | Cause | Fix |
    |-------|-------|-----|
    | `e.values.join is not a function` | Raw string/array passed as filter value | Use `f_str(["val"])`, `f_bool(True)`, etc. |
    | `No such view` | `table` field uses display label or wrong format | Use `schema__table` double-underscore format |
    | `fields outside of topic` | Field prefix uses view file name instead of join alias | Check topic YAML `joins:` — use join alias, not view file name |
    | Filter silently ignored | `tileFilterMap` entry missing a filter ID | Add every `fid` to every tile's entry; use `False` to exclude |
    | Tile returns 0 rows silently | Date filter key uses `[fiscal_quarter]` or `[quarter]` | Use `[date]` or `[month]` for filter key |
    | "No chart available" | `visType: "basic"` set with an empty spec | Only set `visType: "basic"` when a non-empty `vis_spec` fills the spec |
    | Wrong tile order | `tile_order` list doesn't match tile `name` exactly | Names are case-sensitive |

    ---

    ## Related Skills

    - **omni-model-builder** — add missing fields/topics before building
    - **omni-sync** — sync model file changes to the remote branch
    ````
  </Accordion>

  <Accordion title="omni_dashboard_builder.py" id="omni-dashboard-builder-py" description="Python builder script">
    Add your `build_<name>()` functions and register them in `DASHBOARDS`, then run with `python3 scripts/omni_dashboard_builder.py`.

    ```python expandable theme={null}
    """
    Omni Dashboard Builder (public/example version)
    ================================================
    Programmatically create/rebuild Omni dashboards via the API.

    Usage:
      OMNI_BASE_URL=https://<tenant>.omniapp.co \
      OMNI_API_KEY=<your-key> \
      OMNI_MODEL_ID=<your-model-id> \
      python3 scripts/omni_dashboard_builder.py
    """

    import json
    import ssl
    import urllib.request
    import urllib.error
    import urllib.parse
    import os
    from typing import Any

    BASE_URL        = os.environ.get("OMNI_BASE_URL", "https://<tenant>.omniapp.co")
    API_KEY         = os.environ.get("OMNI_API_KEY", "")
    SHARED_MODEL_ID = os.environ.get("OMNI_MODEL_ID", "")

    if not API_KEY or not SHARED_MODEL_ID:
        raise SystemExit("Set OMNI_BASE_URL, OMNI_API_KEY, and OMNI_MODEL_ID before running.")

    _ctx = ssl.create_default_context()
    # SSL troubleshooting: if you see CERTIFICATE_VERIFY_FAILED in a corporate environment,
    # set SSL_CERT_FILE to your CA bundle path. Never disable TLS verification in production.

    def api(method, path, body=None, params=None):
        url = f"{BASE_URL}/api{path}"
        if params:
            url += "?" + urllib.parse.urlencode(params)
        data = json.dumps(body).encode() if body else None
        req = urllib.request.Request(url, data=data, method=method, headers={
            "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json",
        })
        try:
            with urllib.request.urlopen(req, context=_ctx) as r:
                return json.loads(r.read())
        except urllib.error.HTTPError as e:
            print(f"HTTP {e.code}: {e.read().decode()[:400]}")
            return None

    def delete_document(doc_id):
        if api("DELETE", f"/v1/documents/{doc_id}") is not None:
            print(f"Deleted {doc_id}")

    def delete_existing_by_name(name):
        """WARNING: deletes every accessible document with this exact name across all folders.
        Use unique, script-managed names and never reuse them for hand-built content."""
        cursor = None
        while True:
            params = {"pageSize": "100"}
            if cursor:
                params["cursor"] = cursor
            data = api("GET", "/v1/documents", params=params)
            if not data:
                break
            for doc in data.get("records", []):
                if doc.get("name") == name:
                    delete_document(doc["identifier"])
            page = data.get("pageInfo", {})
            if not page.get("hasNextPage"):
                break
            cursor = page.get("nextCursor")

    def export_document(doc_id):
        """Uses /api/unstable/ endpoints — field-tested workaround for layout, tileFilterMap,
        and vis config persistence, still beta per docs.omni.co as of this writing. Move to the
        v2 documents create/patch/publish flow (containers/controls) once that schema is public.
        Regression-test after Omni upgrades if creation breaks."""
        req = urllib.request.Request(
            f"{BASE_URL}/api/unstable/documents/{doc_id}/export",
            headers={"Authorization": f"Bearer {API_KEY}"},
        )
        with urllib.request.urlopen(req, context=_ctx) as r:
            return json.loads(r.read())

    def create_dashboard(name, tiles, layout=None, folder_path=None, model_id=SHARED_MODEL_ID,
                        filter_config=None, filter_order=None, tile_filter_map=None, tile_order=None):
        delete_existing_by_name(name)
        if tile_order is not None:
            order_index = {n: i for i, n in enumerate(tile_order)}
            tiles = sorted(tiles, key=lambda t: order_index.get(t["name"], 9999))
        old_to_new = {t["queryIdentifierMapKey"]: str(i + 1) for i, t in enumerate(tiles)}
        for t in tiles:
            t["queryIdentifierMapKey"] = old_to_new[t["queryIdentifierMapKey"]]
        if layout is not None:
            layout = [{**item, "i": old_to_new[item["i"]]} for item in layout if item["i"] in old_to_new]
        if tile_filter_map is not None:
            tile_filter_map = {old_to_new[k]: v for k, v in tile_filter_map.items() if k in old_to_new}

        post_body = {"modelId": model_id, "name": name, "queryPresentations": tiles}
        if filter_config:
            post_body["filterConfig"] = filter_config
            post_body["filterOrder"]  = filter_order or list(filter_config.keys())
        result = api("POST", "/v1/documents", post_body)
        if not result:
            return None
        doc_id = result["workbook"]["identifier"]

        if layout is None and tile_filter_map is None and folder_path is None:
            print(f"Created: {BASE_URL}/dashboards/{doc_id}")
            return doc_id

        if layout is not None:
            pos_by_key = {item["i"]: (item["y"], item["x"]) for item in layout}
            tiles_sorted = sorted(tiles, key=lambda t: pos_by_key.get(t["queryIdentifierMapKey"], (9999, 9999)))
            old_to_new = {t["queryIdentifierMapKey"]: str(i + 1) for i, t in enumerate(tiles_sorted)}
            for t in tiles_sorted:
                t["queryIdentifierMapKey"] = old_to_new[t["queryIdentifierMapKey"]]
            layout = [{**item, "i": old_to_new[item["i"]]} for item in layout if item["i"] in old_to_new]
            if tile_filter_map is not None:
                tile_filter_map = {old_to_new[k]: v for k, v in tile_filter_map.items() if k in old_to_new}
            post_body["queryPresentations"] = tiles_sorted
            result2 = api("POST", "/v1/documents", post_body)
            if not result2:
                return None
            delete_document(doc_id)
            doc_id = result2["workbook"]["identifier"]

        exp = export_document(doc_id)
        if layout is not None:
            exp["dashboard"]["metadata"]["layouts"]["lg"] = layout
        if tile_filter_map is not None:
            exp["dashboard"]["metadata"]["tileFilterMap"] = tile_filter_map

        vis_spec_map   = {t["name"]: t["vis_spec"]   for t in tiles if "vis_spec" in t}
        vis_type_map   = {t["name"]: t["visType"]    for t in tiles if t.get("visType")}
        chart_type_map = {t["name"]: t["chart_type"] for t in tiles if t.get("chart_type")}
        if vis_spec_map or vis_type_map or chart_type_map:
            memberships = (exp["dashboard"].get("queryPresentationCollection", {})
                          .get("queryPresentationCollectionMemberships", []))
            for m in memberships:
                qp = m.get("queryPresentation", m)
                tile_name = qp.get("name")
                if tile_name in vis_spec_map:
                    if "visConfig" not in qp or qp["visConfig"] is None:
                        qp["visConfig"] = {}
                    spec = vis_spec_map[tile_name]
                    qp["visConfig"]["spec"] = spec
                    if "markdownConfig" in spec:
                        qp["visConfig"]["chartType"] = "kpi"
                        qp["visConfig"]["visType"]   = "omni-kpi"
                    else:
                        qp["visConfig"]["chartType"] = None
                        qp["visConfig"]["visType"]   = "basic"
                    qp["automaticVis"] = False
                elif tile_name in vis_type_map:
                    if "visConfig" not in qp or qp["visConfig"] is None:
                        qp["visConfig"] = {}
                    qp["visConfig"]["visType"] = vis_type_map[tile_name]
                if tile_name in chart_type_map:
                    qp["prefersChart"] = True
                    qp["automaticVis"] = True

        import_body = {
            "baseModelId": model_id, "exportVersion": "0.1",
            "document": exp["document"], "dashboard": exp["dashboard"],
            "queryModels": exp.get("queryModels", {}), "workbookModel": exp.get("workbookModel"),
        }
        if filter_config:
            import_body["filterConfig"] = filter_config
            import_body["filterOrder"]  = filter_order or list(filter_config.keys())
        if folder_path is not None:
            import_body["folderPath"] = folder_path

        imported = api("POST", "/unstable/documents/import", import_body)
        if not imported:
            return None
        ident = imported["workbook"]["identifier"]
        delete_document(doc_id)
        print(f"Created: {BASE_URL}/dashboards/{ident}")
        return ident

    # ── Filter helpers ─────────────────────────────────────────────────────────────

    def f_str(values):         return {"type": "string",  "kind": "EQUALS",                   "values": values,  "is_negative": False}
    def f_str_neg(values):     return {"type": "string",  "kind": "EQUALS",                   "values": values,  "is_negative": True}
    def f_str_contains(values):return {"type": "string",  "kind": "CONTAINS",                 "values": values,  "is_negative": False}
    def f_str_contains_neg(v): return {"type": "string",  "kind": "CONTAINS",                 "values": v,       "is_negative": True}
    def f_bool(value):         return {"type": "boolean", "is_negative": not value, "treat_nulls_as_false": False}
    def f_num(values):         return {"type": "number",  "kind": "EQUALS",                   "values": values,  "is_negative": False}
    def f_not_null():          return {"type": "null",    "is_negative": True}
    def f_date_after(rel):     return {"type": "date",    "kind": "ON_OR_AFTER",              "left_side": rel,  "is_negative": False}
    def f_date_duration(s, d): return {"type": "date",    "kind": "TIME_FOR_INTERVAL_DURATION","left_side": s, "right_side": d, "is_negative": False}

    def sort_asc(col):  return {"column_name": col, "sort_descending": False}
    def sort_desc(col): return {"column_name": col, "sort_descending": True}

    # ── Query builder ──────────────────────────────────────────────────────────────

    def query(view, topic, fields, filters=None, sorts=None, limit=500):
        return {
            "table": view, "join_paths_from_topic_name": topic,
            "fields": fields, "filters": filters or {}, "sorts": sorts or [],
            "limit": limit, "version": 8, "modelId": SHARED_MODEL_ID,
            "metadata": {}, "row_totals": {}, "pivots": [], "calculations": [],
            "fill_fields": [], "join_via_map": {}, "column_totals": {},
            "custom_summary_types": {}, "rewriteSql": True, "default_group_by": True,
            "dbtMode": False, "dimensionIndex": 0, "column_limit": 50, "userEditedSQL": "",
        }

    def cfg_kpi(field, label):
        import uuid
        return {
            "alignment": "left", "fontKPISize": "", "fontBodySize": "", "fontLabelSize": "",
            "verticalAlignment": "top",
            "markdownConfig": [{"id": str(uuid.uuid4()), "type": "number", "config": {
                "field": {"row": "_first", "field": {"name": field, "pivotMap": {}}, "label": {"value": label}},
                "descriptionBefore": "",
            }}],
        }

    def cfg_table():
        return {"tableType": "stretch", "rowBanding": {"enabled": False, "bandSize": 1},
                "expandedRows": {}, "hideIndexColumn": False, "truncateHeaders": True,
                "showDescriptions": True, "visColumnDisplay": "hide-view-name"}

    def cfg_line(x_field, measure_field, color_field):
        return {
            "x": {"field": {"name": x_field}}, "mark": {"type": "line"},
            "color": {"field": {"name": color_field}},
            "series": [{"field": {"name": measure_field}, "yAxis": "y"}],
            "tooltip": [{"field": {"name": x_field}}, {"field": {"name": measure_field}}, {"field": {"name": color_field}}],
            "version": 0, "behaviors": {"stackMultiMark": False}, "configType": "cartesian", "_dependentAxis": "y",
        }

    _X_HORIZ = {"axis": {"label": {"format": {"angle": 0}}}}

    def cfg_bar(x_field, series_fields, stack=False):
        color = {"_stack": "stack"} if stack else {}
        return {
            "x": {"field": {"name": x_field}, **_X_HORIZ}, "mark": {"type": "bar"}, "color": color,
            "series": [{"field": {"name": f}, "yAxis": "y"} for f in series_fields],
            "tooltip": [{"field": {"name": x_field}}] + [{"field": {"name": f}} for f in series_fields],
            "version": 0, "behaviors": {"stackMultiMark": False}, "configType": "cartesian", "_dependentAxis": "y",
        }

    def tile(name, key, q, chart_type=None, prefers_chart=None, config=None, vis_spec=None):
        if chart_type:
            q = {**q, "visConfig": {"chartType": chart_type}}
        is_kpi   = isinstance(config, dict) and "markdownConfig" in config
        is_table = isinstance(config, dict) and "tableType" in config
        if is_kpi:
            vis_spec, config, vis_type = config, None, "omni-kpi"
        elif is_table:
            vis_type = "omni-table"
        elif vis_spec is not None:
            vis_type = "basic"
        else:
            vis_type = None
        prefers = prefers_chart if prefers_chart is not None else (vis_spec is not None or chart_type is not None)
        t = {"name": name, "prefersChart": prefers, "visType": vis_type,
            "queryIdentifierMapKey": key, "topicName": q.get("join_paths_from_topic_name"), "query": q}
        if chart_type is not None: t["chart_type"] = chart_type
        if config is not None:     t["config"] = config
        if vis_spec is not None:   t["vis_spec"] = vis_spec
        return t

    # ── Example dashboard ──────────────────────────────────────────────────────────

    ORDERS_VIEW, ORDERS_TOPIC = "analytics__orders", "orders"

    SALES_FILTER_CONFIG = {"date_filter": {
        "type": "date", "label": "Date range",
        "fieldName": f"{ORDERS_TOPIC}.created[date]",
        "kind": "TIME_FOR_INTERVAL_DURATION",
        "left_side": "6 months ago", "right_side": "6 months", "is_negative": False,
    }}
    SALES_FILTER_ORDER = ["date_filter"]
    SALES_LAYOUT = [
        {"i": "1", "x":  0, "y":  0, "w":  8, "h": 23},
        {"i": "2", "x":  8, "y":  0, "w":  8, "h": 23},
        {"i": "3", "x": 16, "y":  0, "w":  8, "h": 23},
        {"i": "4", "x":  0, "y": 23, "w": 24, "h": 30},
        {"i": "5", "x":  0, "y": 53, "w": 24, "h": 26},
    ]
    _F_DATE = {"date_filter": f"{ORDERS_TOPIC}.created[date]"}
    SALES_TILE_FILTER_MAP = {str(i): _F_DATE for i in range(1, 6)}

    def build_sales_overview():
        tiles = []
        tiles.append(tile("Total Orders",  "1", query(ORDERS_VIEW, ORDERS_TOPIC, ["orders.order_count"]),  config=cfg_kpi("orders.order_count",  "Total Orders")))
        tiles.append(tile("Total Revenue", "2", query(ORDERS_VIEW, ORDERS_TOPIC, ["orders.total_revenue"]), config=cfg_kpi("orders.total_revenue", "Total Revenue")))
        tiles.append(tile("Revenue by Region", "3",
            query(ORDERS_VIEW, ORDERS_TOPIC, ["orders.region", "orders.total_revenue"], sorts=[sort_desc("orders.total_revenue")]),
            vis_spec=cfg_bar("orders.region", ["orders.total_revenue"])))
        tiles.append(tile("Orders by Status", "4",
            query(ORDERS_VIEW, ORDERS_TOPIC, ["orders.status", "orders.order_count", "orders.total_revenue"],
                  filters={"orders.status": f_not_null()}, sorts=[sort_desc("orders.order_count")]),
            config=cfg_table()))
        return tiles

    DASHBOARDS = {
        "sales-overview": {
            "name":            "Sales Overview (example)",
            "build":           build_sales_overview,
            "layout":          SALES_LAYOUT,
            "folder":          "examples",
            "filter_config":   SALES_FILTER_CONFIG,
            "filter_order":    SALES_FILTER_ORDER,
            "tile_filter_map": SALES_TILE_FILTER_MAP,
        },
    }

    def main():
        for key, cfg in DASHBOARDS.items():
            print(f"\n── Building {key} ──")
            tiles = cfg["build"]()
            create_dashboard(
                name=cfg["name"], tiles=tiles, layout=cfg.get("layout"),
                folder_path=cfg.get("folder"), filter_config=cfg.get("filter_config"),
                filter_order=cfg.get("filter_order"), tile_filter_map=cfg.get("tile_filter_map"),
                tile_order=cfg.get("tile_order"),
            )

    if __name__ == "__main__":
        main()
    ```
  </Accordion>
</AccordionGroup>
