---
name: Connect To Power Bi Desktop
slug: connect-to-power-bi-desktop
category: AI Engineering
description: Connect To Power Bi Desktop uses PowerShell with TOM and ADOMD.NET to query and edit the local Analysis Services model in Power BI Desktop. It is used for model enumeration, DAX queries, metadata changes, and Desktop Bridge reload or screenshot checks.
github: "https://github.com/data-goblin/power-bi-agentic-development/tree/main/plugins/pbi-desktop/skills/connect-pbid"
language: "C#"
stars: 872
forks: 129
install: "npx degit https://github.com/data-goblin/power-bi-agentic-development/tree/main/plugins/pbi-desktop/skills/connect-pbid ~/.claude/skills/connect-pbid"
installs_to: ~/.claude/skills/connect-pbid
source_path: plugins/pbi-desktop/skills/connect-pbid/SKILL.md
collection_size: 25
category_size: 2451
collection_url: "https://dirskills.com/collections/data-goblin/power-bi-agentic-development"
added: 2026-08-22T05:21:29.468Z
last_synced: 2026-08-22T05:21:29.468Z
canonical_url: "https://dirskills.com/skills/connect-to-power-bi-desktop"
---

# Connect To Power Bi Desktop

Connect To Power Bi Desktop uses PowerShell with TOM and ADOMD.NET to query and edit the local Analysis Services model in Power BI Desktop. It is used for model enumeration, DAX queries, metadata changes, and Desktop Bridge reload or screenshot checks.

**Install:**

```bash
npx degit https://github.com/data-goblin/power-bi-agentic-development/tree/main/plugins/pbi-desktop/skills/connect-pbid ~/.claude/skills/connect-pbid
```

## README

# Connect to Power BI Desktop (Local Analysis Services)

> **CRITICAL:** Record mistakes, surprises, and model-specific nuances encountered while using this skill in `.claude/rules/connect-pbid.md`. This file must begin with "Learnings from Claude about connecting to semantic models via the connect-pbid skill". Write only active reference notes (e.g. "QueryGroup property returns an object; access .Folder for the name string"); do not log a changelog or history of events. Omit anything already documented in the skill or its references. Keep the file under 1500 characters at all times; prune stale entries when adding new ones. Do not over-attend to this file; update it only when something genuinely unexpected is discovered.

> **Note:** No MCP server is required. Use PowerShell with TOM/ADOMD.NET for the local model.
> When the report canvas is also in scope, pair it with `pbir` for report operations; never patch
> report JSON directly.

Expert guidance for connecting to Power BI Desktop's local tabular model via the Tabular Object Model (TOM) and ADOMD.NET in PowerShell. Covers connection, enumeration, DAX queries, query traces, and full model modification.


## When to Use This Skill

Activate only when the Tabular Editor CLI or a Power BI MCP server is unavailable. TOM is more reliable than direct TMDL editing because it validates changes against the engine and applies them atomically.

**WARNING:** This skill does NOT support remote models via the XMLA endpoint. For Direct Lake models or models hosted in Fabric, use the Tabular Editor CLI or a Power BI MCP server instead; the local Analysis Services proxy does not expose Direct Lake databases to external TOM/ADOMD.NET connections.

## Model and report: routing

Power BI Desktop exposes the model and the report as two separate local surfaces. This skill owns the model surface and report-canvas verification, and routes report authoring to the right skill:

- **Model** (tables, columns, measures, relationships, roles, calculation groups, refresh): this skill, via TOM/ADOMD over the local Analysis Services instance. For model edits, prefer the `te` CLI or a model MCP when available; fall back to this skill's TOM when they are not (see "When to Use This Skill").
- **Report-canvas verification** (reload after edits, screenshot pages): this skill, the raw Desktop Bridge named-pipe API (section 13).
- **Report authoring** (visuals, pages, formatting, filters, bookmarks, themes): the `pbir-cli` skill in the reports plugin (it drives the `pbir` CLI). The Desktop Bridge here only reloads and screenshots; it never edits visuals. Route every visual or page change to `pbir-cli`.
- **Report JSON edited directly** (only when `pbir` is unavailable): the `pbir-format` skill in the pbip plugin.

Full loop on an open PBIP: change the model with TOM here, change visuals with `pbir-cli`, then reload and screenshot with the Desktop Bridge here to verify, and iterate.


## Critical

- Power BI Desktop must be open with a model loaded before connecting; if there are errors it is likely due to a "thin report" connected to a remote model, or a Direct Lake model (which uses a remote proxy that blocks external connections)
- The local Analysis Services instance only accepts connections from `localhost`
- Multiple PBI Desktop files open means multiple `msmdsrv.exe` processes on different ports. Connect to each port, read `$server.Databases[0].Name`, and ask the user which model to work with if more than one is found. When the `pbir` CLI is installed, prefer `pbir desktop list` to map each Desktop PID to the exact file it has open (see Section 2a)
- A workspace engine reporting `Databases: 0` belongs to a thin report (live connection to a remote model); there is no local model to connect to. Query thin reports through their remote model instead (`pbir model -q` routes there automatically)
- Always use a timeout of 60000ms or higher for PowerShell commands via Bash
- **Shell escaping**: Bash eats PowerShell `$` variables (`$env:TEMP`, `$server`, etc.) silently. Two options: (1) single-quote the `-Command` arg so Bash passes `$` literally to PowerShell; (2) write a `.ps1` file with a heredoc (single-quoted delimiter preserves `$`) and use `-File`. On macOS via Parallels, the `prlctl` -> `cmd.exe` -> `powershell.exe` chain adds extra escaping layers; `.ps1` files are more reliable for complex scripts but inline `-Command` with single quotes works for short commands.
- **Always use `-ExecutionPolicy Bypass`** when running PowerShell commands or scripts. Windows blocks unsigned scripts by default.
- **Script file location** -- persistent scripts should go in the agent harness's scripts directory for the project (`.claude/scripts/`, `.github/scripts/`, `.cursor/scripts/`, `.gemini/scripts/`, etc. depending on the harness). Ephemeral or throwaway scripts should go in a project `tmp/` directory (which should be `.gitignored`). Do not write scripts to `./` root or `/tmp/`.
- Do not modify model metadata without explicit user direction
- Always call `$model.SaveChanges()` to persist modifications; without it, changes are discarded
- For macOS users running PBI Desktop in Parallels, see [parallels-macos.md](./references/parallels-macos.md)
- **Validation hooks** are active for this plugin; they validate DAX references, enforce measure metadata, check referential integrity, and report compatibility level upgrade opportunities. Toggle checks in `hooks/config.yaml`.


## 1. Prerequisites

| Requirement | Description |
|-------------|-------------|
| **Power BI Desktop** | Open with a model loaded (`.pbix` or `.pbip`) |
| **PowerShell** | Available on the machine running PBI Desktop |
| **NuGet CLI** | For package installation (`winget install Microsoft.NuGet`) |
| **TOM NuGet Package** | `Microsoft.AnalysisServices.retail.amd64` -- model metadata |
| **ADOMD.NET Package** | `Microsoft.AnalysisServices.AdomdClient.retail.amd64` -- DAX queries |

Install both packages only if not already present:

```powershell
$pkgDir = "$env:TEMP\tom_nuget"
if (-not (Test-Path "$pkgDir\Microsoft.AnalysisServices.retail.amd64")) {
    nuget install Microsoft.AnalysisServices.retail.amd64 -OutputDirectory $pkgDir -ExcludeVersion
}
if (-not (Test-Path "$pkgDir\Microsoft.AnalysisServices.AdomdClient.retail.amd64")) {
    nuget install Microsoft.AnalysisServices.AdomdClient.retail.amd64 -OutputDirectory $pkgDir -ExcludeVersion
}
```

Packages install DLLs under `lib\net45\`. Load with `Add-Type -Path`.

> **If a TOM operation fails** with a compatibility level error or missing type, the `.retail.amd64` package may be too old. A newer package (`Microsoft.AnalysisServices`, .NET 8+) ships with more recent TOM features. See [daxlib.md](./references/daxlib.md) for details on package differences.


## 2. Quickstart

Find the port, load TOM, connect, enumerate -- in one script:

```powershell
# Find ports (deduped; netstat lists IPv4 and IPv6 entries per port)
$pids = (Get-Process msmdsrv -ErrorAction SilentlyContinue).Id
$ports = netstat -ano | Select-String "LISTENING" |
    Where-Object { $pids -contains ($_ -split "\s+")[-1] } |
    ForEach-Object { ($_ -split "\s+")[2] -replace ".*:" } |
    Select-Object -Unique

# Load TOM
$basePath = "$env:TEMP\tom_nuget\Microsoft.AnalysisServices.retail.amd64\lib\net45"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Core.dll"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Tabular.dll"

# Connect to the first port that hosts a model; skip thin-report engines (0 databases)
$server = New-Object Microsoft.AnalysisServices.Tabular.Server
foreach ($p in $ports) {
    $server.Connect("Data Source=localhost:$p")
    if ($server.Databases.Count -eq 0) {
        Write-Output "localhost:$p hosts no model (thin report); trying next port"
        $server.Disconnect()
        continue
    }
    break
}
$model = $server.Databases[0].Model

# Enumerate
foreach ($table in $model.Tables) {
    Write-Output "TABLE: [$($table.Name)] ($($table.Columns.Count) cols, $($table.Measures.Count) measures)"
}
Write-Output "Relationships: $($model.Relationships.Count)"

$server.Disconnect()
```

**Port discovery methods:**

| Method | Install Type | Command |
|--------|-------------|---------|
| Port file | Non-Store PBI Desktop | `Get-Content "$env:LOCALAPPDATA\Microsoft\Power BI Desktop\AnalysisServicesWorkspaces\*\Data\msmdsrv.port.txt"` |
| Port file | Store PBI Desktop | `Get-Content "$env:LOCALAPPDATA\Packages\Microsoft.MicrosoftPowerBIDesktop_*\LocalState\AnalysisServicesWorkspaces\*\Data\msmdsrv.port.txt"` |
| netstat | Any | `netstat -ano \| findstr LISTENING \| findstr <PID>` |


## 2a. Correlating Ports to Reports (Multiple Instances)

A port alone does not identify the report it serves; correlate before connecting to avoid modifying the wrong model. With the `pbir` CLI and Desktop's "external tool access" preview feature enabled, `pbir desktop list` shows each Desktop PID with the exact file it has open. Map ports to those PIDs through the process tree (each `msmdsrv.exe` is a child of its `PBIDesktop.exe`):

```powershell
$conns = Get-NetTCPConnection -State Listen
foreach ($proc in Get-Process msmdsrv -ErrorAction SilentlyContinue) {
    $port = ($conns | Where-Object OwningProcess -eq $proc.Id | Select-Object -First 1).LocalPort
    $parent = (Get-WmiObject Win32_Process -Filter "ProcessId=$($proc.Id)").ParentProcessId
    Write-Output "port $port -> msmdsrv $($proc.Id) -> PBIDesktop $parent"
}
```

An engine reporting `Databases: 0` is a thin report's workspace; no local model exists. Query the remote model instead (`pbir model -q` routes there automatically).


## 3. Loading TOM, Connecting, and Saving Changes

### Load Assemblies

```powershell
$basePath = "$env:TEMP\tom_nuget\Microsoft.AnalysisServices.retail.amd64\lib\net45"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Core.dll"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Tabular.dll"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Tabular.Json.dll"
```

### Connect

```powershell
$server = New-Object Microsoft.AnalysisServices.Tabular.Server
$server.Connect("Data Source=localhost:<PORT>")

# PBI Desktop always has exactly one database
$db = $server.Databases[0]
$model = $db.Model
```

### Save Changes

Only save after all changes are made. After modifications, persist with:

```powershell
$model.SaveChanges()
```

Changes appear immediately in PBI Desktop. The user cannot undo with `Ctrl+Z` in Power BI, which is a disadvantage of this approach.

### Disconnect

**IMPORTANT:** Remember to disconnect after modifications are done. NEVER remain connected, which can lead to orphaned processes.

```powershell
$server.Disconnect()
```

### Connection Properties

```powershell
Write-Output "Server: $($server.Name)"
Write-Output "Version: $($server.Version)"
Write-Output "Database: $($db.Name)"
Write-Output "Compatibility: $($db.CompatibilityLevel)"
```


## 4. Refreshing the Model

Trigger a data refresh via TMSL (Tabular Model Scripting Language) or TOM's `RequestRefresh` API. This re-executes Power Query/M expressions and reloads data into the VertiPaq engine.

```powershell
# Full refresh of a single table via TMSL
$dbName = $server.Databases[0].Name
$tmsl = '{ "refresh": { "type": "full", "objects": [{ "database": "' + $dbName + '", "table": "Sales" }] } }'
$server.Execute($tmsl)

# Or via TOM RequestRefresh API
$model.Tables["Sales"].RequestRefresh([Microsoft.AnalysisServices.Tabular.RefreshType]::Full)
$model.SaveChanges()
```

| Refresh Type | Behaviour |
|-------------|-----------|
| `full` | Drop data, re-query source, recalculate DAX |
| `calculate` | Recalculate DAX only (no source query) |
| `automatic` | Engine decides per-partition what's needed |
| `dataOnly` | Re-query source but skip DAX recalculation |

For detailed examples and all refresh methods, see [refresh-model.md](./references/refresh-model.md).


## 5. Querying with DAX

### Load ADOMD.NET

```powershell
Add-Type -Path "$env:TEMP\tom_nuget\Microsoft.AnalysisServices.AdomdClient.retail.amd64\lib\net45\Microsoft.AnalysisServices.AdomdClient.dll"
```

### Open a Connection

```powershell
$conn = New-Object Microsoft.AnalysisServices.AdomdClient.AdomdConnection
$conn.ConnectionString = "Data Source=localhost:<PORT>"
$conn.Open()
```

### Execute a Query

All queries should preferably use `SUMMARIZECOLUMNS`.
Check `dax.guide` online for information about DAX functions, if necessary.

**Important:** ADOMD.NET returns fully-qualified column names without quotes around the table name (e.g., `Brands[Brand Class]` not `Brand Class`; measure projections come back as `[@Alias]`). Do not access columns by short name (`$reader["Brand Class"]`) -- it fails silently and returns blank. Use `$reader.GetName($i)` to discover column names, then access by index:

```powershell
$cmd = $conn.CreateCommand()
$cmd.CommandText = "EVALUATE SUMMARIZECOLUMNS('Table'[Column], ""@MeasureName"", [Measure])"

$reader = $cmd.ExecuteReader()

# Always iterate by index and use GetName() to map columns
while ($reader.Read()) {
    for ($i = 0; $i -lt $reader.FieldCount; $i++) {
        Write-Output "$($reader.GetName($i)): $($reader.GetValue($i))"
    }
    Write-Output "---"
}
$reader.Close()
```

### DAX Rules

- **Always fully qualify column references** with single-quoted table names: `'Sales'[Amount]`, not `[Amount]`. This applies everywhere -- measures, calculated columns, queries. Unqualified columns cause ambiguity errors.
- Table names are **always** single-quoted in DAX: `'Sales'[Amount]`, `'D&D 5E Monsters'[CR]`. Even simple names like `Sales` should be quoted as `'Sales'` for consistency.
- **Measure references are the only exception** -- they are always unqualified: `[Total Revenue]`
- String literals in DAX use double quotes, escaped as `""` inside PowerShell here-strings

### Query Patterns

```powershell
# Full table scan
$cmd.CommandText = "EVALUATE 'Sales'"

# Filtered with CALCULATETABLE
$cmd.CommandText = "EVALUATE CALCULATETABLE('Sales', 'Sales'[Region] = ""West"")"

# Aggregation
$cmd.CommandText = "EVALUATE SUMMARIZECOLUMNS('Date'[Year], ""@Total"", SUM('Sales'[Amount]))"

# Scalar via ROW
$cmd.CommandText = "EVALUATE ROW(""Result"", COUNTROWS('Sales'))"

# DMV queries (model metadata via SQL-like syntax)
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_TABLES"
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_MEASURES"
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_COLUMNS"
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_RELATIONSHIPS"
```

### Close Connection

```powershell
$conn.Close()
```


## 6. Modifying a Semantic Model

All modifications require a TOM connection (section 3). Call `$model.SaveChanges()` after each batch of changes.

### A. CRUD by Object Type

For full CRUD examples of every object type, see [tom-object-types.md](./references/tom-object-types.md).

**Common object types and their TOM collections** (not exhaustive -- see [Microsoft TOM API docs](https://learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular) for the full namespace):

| Object | Collection | Create | Read | Update | Delete |
|--------|-----------|--------|------|--------|--------|
| Table | `$model.Tables` | `New-Object ...Table` | `$model.Tables["Name"]` | Set properties | `.Remove($obj)` |
| Column | `$table.Columns` | `New-Object ...DataColumn` | `$table.Columns["Name"]` | Set properties | `.Remove($obj)` |
| Measure | `$table.Measures` | `New-Object ...Measure` | `$table.Measures["Name"]` | Set properties | `.Remove($obj)` |
| Calculated Column | `$table.Columns` | `New-Object ...CalculatedColumn` | Filter by type | Set `.Expression` | `.Remove($obj)` |
| Calculated Table | `$model.Tables` | Table + calculated partition | Check partition type | Set partition expr | `.Remove($obj)` |
| Relationship | `$model.Relationships` | `New-Object ...SingleColumnRelationship` | Index or filter | Set properties | `.Remove($obj)` |
| Hierarchy | `$table.Hierarchies` | `New-Object ...Hierarchy` | `$table.Hierarchies["Name"]` | Modify levels | `.Remove($obj)` |
| Role | `$model.Roles` | `New-Object ...ModelRole` | `$model.Roles["Name"]` | Set permissions | `.Remove($obj)` |
| Perspective | `$model.Perspectives` | `New-Object ...Perspective` | `$model.Perspectives["Name"]` | Toggle membership | `.Remove($obj)` |
| Culture | `$model.Cultures` | `New-Object ...Culture` | `$model.Cultures["en-US"]` | Set translations | `.Remove($obj)` |
| Partition | `$table.Partitions` | `New-Object ...Partition` | `$table.Partitions["Name"]` | Set source/expression | `.Remove($obj)` |
| Annotation | Any object | `$obj.Annotations.Add(...)` | `$obj.Annotations["Key"]` | Set `.Value` | `.Remove($obj)` |
| Expression | `$model.Expressions` | `New-Object ...NamedExpression` | `$model.Expressions["Name"]` | Set `.Expression` | `.Remove($obj)` |
| Data Source | `$model.DataSources` | `New-Object ...StructuredDataSource` | `$model.DataSources["Name"]` | Set connection | `.Remove($obj)` |
| Calculation Group | `$model.Tables` | Table with `CalculationGroup` | Filter by type | Add/remove items | `.Remove($obj)` |

**Quick examples (inline):**

```powershell
# Add a measure
$m = New-Object Microsoft.AnalysisServices.Tabular.Measure
$m.Name = "Total Revenue"
$m.Expression = "SUM(Sales[Amount])"
$m.FormatString = "`$#,0"
$m.Description = "Sum of all sales amounts"
$model.Tables["Sales"].Measures.Add($m)

# Add a relationship
$rel = New-Object Microsoft.AnalysisServices.Tabular.SingleColumnRelationship
$rel.Name = "Sales_to_Date"
$rel.FromColumn = $model.Tables["Sales"].Columns["DateKey"]
$rel.ToColumn = $model.Tables["Date"].Columns["DateKey"]
$rel.FromCardinality = [Microsoft.AnalysisServices.Tabular.RelationshipEndCardinality]::Many
$rel.ToCardinality = [Microsoft.AnalysisServices.Tabular.RelationshipEndCardinality]::One
$model.Relationships.Add($rel)

# Rename a column
$model.Tables["Sales"].Columns["amt"].Name = "Amount"

# Hide a table
$model.Tables["Bridge"].IsHidden = $true

# Delete a measure
$m = $model.Tables["Sales"].Measures["Old Measure"]
$model.Tables["Sales"].Measures.Remove($m)

# Add a role with RLS
$role = New-Object Microsoft.AnalysisServices.Tabular.ModelRole
$role.Name = "Region Filter"
$role.ModelPermission = [Microsoft.AnalysisServices.Tabular.ModelPermission]::Read
$model.Roles.Add($role)
$tp = New-Object Microsoft.AnalysisServices.Tabular.TablePermission
$tp.Table = $model.Tables["Sales"]
$tp.FilterExpression = "[Region] = USERNAME()"
$role.TablePermissions.Add($tp)

$model.SaveChanges()
```

### B. Discovering Object Types, Properties, and Setting Values

For complete TOM object type tables, PowerShell reflection patterns for discovering properties and enum values, and reading/setting property examples, see **`references/tom-object-types.md`**.


## 7. Validating DAX Expressions

Before saving measure/column expressions, validate them by test-executing against the live model. This catches syntax errors, missing column references, and circular dependencies without persisting bad metadata.

```powershell
# Validate a DAX expression before adding it as a measure
$testExpr = "SUM('Sales'[Amount]) / COUNTROWS('Sales')"
$cmd = $conn.CreateCommand()
$cmd.CommandText = "EVALUATE ROW(`"@Test`", $testExpr)"
try {
    $reader = $cmd.ExecuteReader()
    $reader.Close()
    Write-Output "VALID"
} catch {
    Write-Output "INVALID: $($_.Exception.Message)"
}
```

For calculated table expressions, wrap in `COUNTROWS`:

```powershell
$tableExpr = "CALENDAR(DATE(2020,1,1), DATE(2030,12,31))"
$cmd.CommandText = "EVALUATE ROW(`"@Count`", COUNTROWS($tableExpr))"
```

For filter expressions (RLS), test with `CALCULATETABLE`:

```powershell
$filterExpr = "'Sales'[Region] = `"West`""
$cmd.CommandText = "EVALUATE CALCULATETABLE(ROW(`"@OK`", 1), $filterExpr)"
```


## 8. Transactions and Rollback

`SaveChanges()` applies all pending modifications in a single implicit transaction. I
