Help us improve
Share bugs, ideas, or general feedback.
Build, edit, or format Power BI .pbip projects (PBIR/TMDL format) using pbi-cli. Use whenever the user asks to add pages, visuals, measures, tables, themes, or M-query data sources to a Power BI Desktop project — anything that touches files under a .Report/ or .SemanticModel/ folder. Also use when fixing PBIP load errors (TMDL parse errors, report.json schema errors, "model has no columns", missing pages after save, themes not applying).
npx claudepluginhub rje-gh/pbi-claudecode-toolkit --plugin pbi-claudecode-toolkitHow this skill is triggered — by the user, by Claude, or both
Slash command
/pbi-claudecode-toolkit:powerbi-buildThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill encodes hard-won lessons from real PBIP builds. The single most destructive failure mode on this platform is the user pressing Ctrl+S at the wrong moment and silently overwriting your work. Section 1 below — the Desktop interaction protocol — exists specifically to prevent that. Read it before doing anything else.
Guides technical evaluation of code review feedback: read fully, restate for understanding, verify against codebase, respond with reasoning or pushback before implementing.
Share bugs, ideas, or general feedback.
This skill encodes hard-won lessons from real PBIP builds. The single most destructive failure mode on this platform is the user pressing Ctrl+S at the wrong moment and silently overwriting your work. Section 1 below — the Desktop interaction protocol — exists specifically to prevent that. Read it before doing anything else.
PBIP edits go through two surfaces: pbi-cli writes files directly to disk (.Report/ folder) and uses AMO to mutate the live model in Desktop's memory. These two surfaces only stay in sync if you respect strict open/save discipline. Every "my page disappeared" or "my theme didn't apply" comes from violating this.
Before any pbi-cli session:
.pbip loaded. pbi-cli connects via localhost:<port> and won't work otherwise.During the pbi-cli session:
pbi table create, column create, measure create, refresh) propagate live via AMO. Desktop sees them immediately. No reopen needed.pbi report add-page, visual add, visual bind, set-container, theme files, direct JSON edits) write to disk only. Desktop's in-memory view does not see them.pbi report reload does NOT make Desktop see report changes. It sends Ctrl+Shift+F5, which is only a data refresh. Report-definition reload requires the user to close-without-save and reopen.
After every close-without-save / reopen, Desktop's port changes. The previous localhost:<port> connection is dead. Just run pbi connect again — auto-detect finds the new port and re-establishes the session. The catalog GUID may also change, so re-run pbi --json database list if you need to pin the catalog explicitly. Don't paste the old port into a fresh connect command.
Error: Desktop synced: <file>.pbip is informational, not fatal. pbi-cli prints "Error:" prefix on this status line but the operation succeeded. Look for the status: created line above it for the real result.
After the pbi-cli session ends — handoff to user:
pbi report validate and pbi database export-tmdl (final snapshot).Quick decision table:
| What you did via pbi-cli | Does Desktop see it live? | User must close-without-save and reopen? |
|---|---|---|
table create, column create, measure create | Yes (AMO live) | No |
table refresh, partition refresh | Yes | No |
report add-page, visual add/bind/set-container | No (disk only) | Yes |
| Direct edits to visual.json or page.json | No (disk only) | Yes |
Theme file write + report.json patch | No (disk only) | Yes |
| Mixed (model + report) | Partially | Yes (because of the report half) |
Default to "yes, close-without-save and reopen." It's never wrong to do it; it's frequently catastrophic to skip it.
Before the first PBIP session on this machine:
pipx inject pbi-cli-tool pywin32
Verify with pipx runpip pbi-cli-tool show pywin32. A regular pip install pywin32 does not work — pbi-cli runs in its own pipx venv and won't find it elsewhere. Without pywin32 in that venv, pbi report reload fails and pbi-cli can't tell Desktop anything has changed. This makes the destruction window in §1 wider — your only signal then is the user saving and noticing things missing.
cd to the .pbip project root. Every pbi report ... command (add-page, validate, set-background, set-visibility) reads/writes files relative to the current working directory. If you run them from anywhere else, the command may report success against the live model but the disk files end up in the wrong location — page exists in pbi report list-pages but pbi visual list --page <id> says "Page not found." Always cd "<project>" first..pbip.pbi connect to auto-detect.pbi connections list and check the Catalog column. If it's empty: pbi --json database list to get the GUID, then pbi connect -d localhost:<port> -C <full-guid>.pbi table list. If it returns "No results" but TMDL on disk has tables, Desktop didn't load the model — have the user close-without-save and reopen, then reconnect.pbi database export-tmdl <SemanticModel>/definition. Snapshot live state to disk before doing anything. Many .pbip files have empty or stale TMDL — this catches it and gives you a baseline.pbi report validate. Bail and fix any pre-existing errors before adding to them. Note: validate checks JSON shape, not field references — a visual referencing a measure that no longer exists will validate cleanly and only show errors at render time.Always: Model → Report → Theme. Run pbi database export-tmdl between phases. It's cheap and means a crash never costs you more than the current phase.
pre-flight → table create → column create → refresh Full → measure create
→ export-tmdl
→ page add → visual add → visual bind → set-container
→ export-tmdl
→ theme file + report.json patch
→ validate → export-tmdl (final)
→ handoff (close-without-save / reopen)
Multi-line M expressions must be piped via stdin — shell escaping breaks them as args:
cat <<'EOF' | pbi table create TableName --mode Import --m-expression -
let
Source = Excel.Workbook(File.Contents("C:\full\path\to\file.xlsx"), null, true),
Sheet = Source{[Item="SheetName",Kind="Sheet"]}[Data],
#"Promoted Headers" = Table.PromoteHeaders(Sheet, [PromoteAllScalars=true]),
#"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"Col1", type text}, {"Col2", Int64.Type}})
in
#"Changed Type"
EOF
pbi table create only creates the partition. Columns must be explicitly added:
pbi column create ColName -t TableName --data-type <string|int64|double|datetime|...> --source-column ColName [--format-string "0"]
After all columns exist, refresh the table to load data:
pbi table refresh TableName --type Full
--type Full is required. --type Automatic is calculation-only and leaves the table empty.
Verify with pbi dax execute "EVALUATE { COUNTROWS(TableName) }". If it errors with "table cannot be used in computations", you skipped columns. If COUNTROWS returns 0, columns aren't bound to source.
pbi measure create "Measure Name" -t TableName -e "<DAX>" --format-string "<format>"
Names with $, spaces, and % work fine — quote them in shell. Verify each with pbi dax execute "EVALUATE { [Measure Name] }" before binding to visuals.
A common mistake: building "Revenue Actual" as CALCULATE(SUM(Fact[Value]), Fact[Line Item] = "Revenue", Fact[Scenario] = "Actual") and then dropping it into a matrix where rows = Line Item. Every row will display the Revenue total, because the measure overrides the row's Line-Item filter context.
When the matrix's row dimension is the dimension you'd otherwise filter, build a generic measure that only filters the dimensions the row context doesn't supply:
pbi measure create "Value Actual" -t Fact -e "CALCULATE(SUM('Fact'[Value]), 'Fact'[Scenario] = \"Actual\")" --format-string "\$#,##0"
pbi measure create "Value Budget" -t Fact -e "CALCULATE(SUM('Fact'[Value]), 'Fact'[Scenario] = \"Budget\")" --format-string "\$#,##0"
Then the row context (Line Item) flows through the measure naturally — each row shows its own value. Keep the row-locked measures (Revenue Actual, EBITDA Actual, etc.) for KPI cards where you specifically want a single line item. Build both kinds.
pbi table create only takes M expressions (Import-mode partition). DAX-defined calculated tables require TMDL hand-editing, which is in the same risk category as M-partition hand-editing (see warning below). If you need a derived table, either: (a) compute it in M and source it that way, (b) use measures with SUMMARIZE patterns, or (c) ask the user to add the calculated table manually in Desktop.
The TMDL partition syntax for M sources is undocumented and version-specific. Wrong guesses (type: m, ``` ``` blocks) cause silent file-load failures that wipe the entire model. Use pbi-cli commands and let export-tmdl write the canonical form.
For reference (do not copy by hand — let export-tmdl produce it), the current Desktop format is:
partition Name = m
mode: import
source =
let
...
in
result
Note source = (with = at end of line) followed by raw indented M code, no backticks.
pbi report add-page -d "Display Name" --width 1280 --height 720
# returns: name: <auto-generated page id>
pbi report set-background <page-id> -c "#FFFFFF" -t 0
pbi visual add --page <page-id> --type <type> -n <name> --x N --y N --width N --height N
pbi visual bind <name> --page <page-id> --category "Table[Col]" --value "Table[Measure]"
pbi visual set-container <name> --page <page-id> --title "..."
pbi report add-page appends pages but doesn't expose ordering or "set as active." For both, edit <Report>/definition/pages/pages.json directly:
{
"pageOrder": [
"executive-summary-pageid",
"pnl-pageid",
"page1-pageid"
],
"activePageName": "executive-summary-pageid"
}
activePageName controls which page is selected when the .pbip opens. Useful for setting an Executive Summary as the landing page and pushing the default blank Page 1 to the end (or pbi report set-visibility --hidden it).
--type valuesbar_chart, line_chart, card, table, matrix. Type strings differ from the resulting PBIR visualType: bar_chart → barChart, matrix → pivotTable.
Call pbi visual bind --value multiple times — each call appends, doesn't replace. Verify with pbi visual get.
pbi visual bind --value silently drops non-measure columns on table/matrixFor --type table, the natural pattern is to bind a string column (PaymentMethod, LocationName) plus several measures into the table's projections. --value only accepts measures. If you pass a categorical column, pbi-cli reports bound but writes nothing — the column is silently dropped. Symptom: the table shows just the measure totals as a single grand-total row with no grouping.
There is no --column flag for tables. Workaround: bind the measures via --value, then edit the visual's visual.json directly to prepend Column projections to Values.projections:
"Values": {
"projections": [
{
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Tbl" } }, "Property": "Cat" } },
"queryRef": "Tbl.Cat",
"nativeQueryRef": "Cat",
"active": true
},
{ "field": { "Measure": ... }, "queryRef": "...", "nativeQueryRef": "..." }
]
}
The tableEx visual mixes Columns and Measures in a single Values projection — order in the array equals column order in the rendered table.
pbi visual bind --value auto-injects a destructive filterConfig on tablesWhen binding measures to a tableEx visual via repeated --value calls, pbi-cli (3.10.x) appends an entry to filterConfig.filters for each measure with "type": "Advanced" and no filter conditions defined. An Advanced filter with no conditions evaluates as restrictive — Power BI filters every row out. Symptom: the table renders with correct headers and zero rows. Looks "blank" or "white."
Fix: strip the entire filterConfig block from the visual.json. Power BI re-creates a clean Categorical filter on next save if needed:
import json
with open(path) as f: d = json.load(f)
if 'filterConfig' in d: del d['filterConfig']
with open(path, 'w') as f: json.dump(d, f, indent=2)
Validate after stripping: pbi report validate. The visual will now render data on the next reopen.
This bug also re-appears after Desktop saves the .pbip — Desktop sometimes re-injects the Advanced filters. If a previously-working table goes blank after a save/reopen cycle, check filterConfig and strip again.
pbi visual bind for a matrix supports --row and --value but not --column. After the bind, edit <page-id>/visuals/<visual-name>/visual.json and add a Columns projection mirroring Rows:
"Columns": {
"projections": [
{
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "TableName" } }, "Property": "ColName" } },
"queryRef": "TableName.ColName",
"nativeQueryRef": "ColName",
"active": true
}
]
}
If a DateDim exists with a Year-Month string column (format "2026-01", "2026-02"...), prefer it over Month Name (text "Jan"/"Feb"/...). String-formatted year-month sorts string-correctly out of the box. Month Name sorts alphabetically (Apr, Aug, Dec...) unless a sortByColumn is set on the column — and adding sortByColumn requires hand-editing TMDL, which is the same risk class as M-partition hand-editing. Don't risk it.
If the model only has a Month text column without sort metadata, use Month Number (int 1–12) instead. Numbers sort naturally; the labels are less pretty but the axis is correct.
A clean three-row layout:
20px outer margins, 20px gaps between visuals.
— (em dash) and other non-ASCII in titles set via --title. They round-trip as ?. Use - (hyphen).pbi visual set-container --title "$..." with literal dollar signs in shell without escaping; use \$ or single quotes.When you write "solid": {"color": "#xxx"} directly into PBIR visual JSON, Desktop strips it on the next save (the value comes back as "solid": {}). Per-visual colors require expression wrappers ("expr": { "ThemeDataColor": ... }) that are version-specific and unstable. Put all colors in the theme JSON instead (Section 7). It's the only reliable path.
Themes managed via the RegisteredResources package are fragile against Save As and .pbix export. Power BI Desktop reformats theme registration on Save As — strips .json from the path, sometimes injects random suffixes (Gaia_Herbs7709083932962756.json), can replace clean names with display names that contain spaces. The result: themes work in pbi-cli sessions, but fall back to the base theme after the user does any Save As, and .pbix export fails outright with "path" errors.
The robust way to apply a theme that survives Save As, Save As .pbix, and casual Desktop edits is to import it via Desktop's UI, not via RegisteredResources files. Steps:
<project>/Themes/MyTheme.json).If the theme schema is invalid, Desktop's import will reject it with a precise validation error (e.g. "Validation error at /visualStyles/tableEx/*/columnHeaders/0/alignment: Must match one of the following enum values: \"Auto\", \"Left\", \"Center\", \"Right\""). Fix the casing/value, retry. The schema is finicky — see "Theme JSON quirks" below.
The RegisteredResources approach (described in the rest of this section) is appropriate for transient themes you control fully via pbi-cli. For anything the user will keep, ship via native import.
The custom theme must satisfy three identical names:
<Report>/StaticResources/RegisteredResources/<NAME>.jsonthemeCollection.customTheme.name in report.jsonresourcePackages[].items[].name in report.jsonIf any two diverge, Power BI Desktop silently falls back to the base theme — no error, visuals just look default. Pick one name (spaces allowed, e.g. "F9 Finance") and use it identically in all three places.
pbi report set-theme is currently unreliable (pbi-cli 3.10.10)It writes invalid report.json:
customTheme.reportVersionAtImport as a string instead of an objectresourcePackages[].items[].type as integer 202 instead of string "CustomTheme"path (BaseThemes/<file> instead of just <file>)f9_theme.json) instead of customTheme nameSkip the command. Manage themes by hand:
<Report>/StaticResources/RegisteredResources/<NAME>.jsonreport.json to add a customTheme block under themeCollection and a RegisteredResources package under resourcePackages (template below)pbi report validatereport.json template for a custom themeAdd the customTheme to themeCollection (alongside baseTheme) and the RegisteredResources package to resourcePackages:
{
"themeCollection": {
"baseTheme": { "name": "...", "reportVersionAtImport": { "visual": "2.4.0", "report": "3.0.0", "page": "2.3.0" }, "type": "SharedResources" },
"customTheme": {
"name": "F9 Finance",
"reportVersionAtImport": { "visual": "2.4.0", "report": "3.0.0", "page": "2.3.0" },
"type": "RegisteredResources"
}
},
"resourcePackages": [
{ "name": "SharedResources", ... },
{
"name": "RegisteredResources",
"type": "RegisteredResources",
"items": [
{ "name": "F9 Finance", "path": "F9 Finance.json", "type": "CustomTheme" }
]
}
]
}
Note the three name matches: customTheme.name = items[].name = "F9 Finance", and the file is F9 Finance.json (path is relative to RegisteredResources/).
The theme schema is different from the per-visual PBIR schema. Specifically:
fontFamily: "Segoe UI" as a plain string — NOT {"value": "Segoe UI"}. The wrapper is for visual JSON only.bold: true for matrix headers, NOT fontBold"color": { "solid": { "color": "#XXXXXX" } } — solid.color is correct in theme files"*": [...] as a property name (e.g. visualStyles."*"."*"."*"). Asterisk is valid as a visual-type key and a styleName key, but not as a property name. Power BI silently ignores the entire visualStyles branch if it sees one.These are inconsistencies in the theme schema that have bitten real builds. Desktop's import-time validator will reject the entire theme if any of these are wrong:
tableEx.columnHeaders.alignment and pivotTable.columnHeaders.alignment require title case: "Auto", "Left", "Center", "Right". But card.title.alignment, barChart.title.alignment, etc. accept lowercase: "left". There's no general rule. When Desktop's validator complains about an alignment value, change the case and retry.name field cause .pbix export failures even when the theme works in .pbip. Use a no-space name (e.g. "GaiaHerbs" not "Gaia Herbs"). The display string the user sees in Desktop's theme gallery is fine to have spaces — it's the name field at the JSON root, plus all three identifiers in §7.1, that must be space-free for .pbix bundling.visualStyles blocks for visual types you haven't tested can reject the whole theme. If you add a waterfallChart, donutChart, or kpi block and any property is wrong, Desktop fails the import — none of the rest of the theme applies. Add styling for new visual types incrementally; verify each addition imports cleanly before moving on.dataColors automatically style any built-in visual type even without an explicit visualStyles.<type> block. So donutChart, pieChart, treemap, etc. inherit the brand palette free. The visualStyles.<type> block is only needed if you want non-default backgrounds, borders, titles, or axis treatments.A working theme structure:
{
"name": "Brand Name",
"dataColors": ["#008F72", "#888888", "#005C49", "#AAAAAA"],
"background": "#FFFFFF",
"foreground": "#353535",
"foregroundNeutralSecondary": "#888888",
"tableAccent": "#008F72",
"textClasses": {
"title": { "fontFace": "Segoe UI", "fontSize": 12, "color": "#353535" },
"header": { "fontFace": "Segoe UI", "fontSize": 11, "color": "#353535" },
"label": { "fontFace": "Segoe UI", "fontSize": 10, "color": "#353535" },
"callout": { "fontFace": "Segoe UI", "fontSize": 26, "color": "#008F72" }
},
"visualStyles": {
"card": {
"*": {
"calloutValue": [
{ "color": { "solid": { "color": "#008F72" } }, "fontFamily": "Segoe UI", "fontSize": 26, "labelDisplayUnits": 0 }
],
"categoryLabels": [
{ "color": { "solid": { "color": "#353535" } }, "fontFamily": "Segoe UI", "fontSize": 11 }
]
}
},
"barChart": {
"*": {
"categoryAxis": [{ "labelColor": { "solid": { "color": "#353535" } }, "fontFamily": "Segoe UI", "fontSize": 10 }],
"valueAxis": [{ "labelColor": { "solid": { "color": "#353535" } }, "fontFamily": "Segoe UI", "fontSize": 10 }],
"legend": [{ "show": true, "fontFamily": "Segoe UI", "fontSize": 10, "labelColor": { "solid": { "color": "#353535" } } }]
}
},
"pivotTable": {
"*": {
"values": [{ "fontFamily": "Segoe UI", "fontSize": 10, "fontColor": { "solid": { "color": "#353535" } } }],
"rowHeaders": [{ "fontFamily": "Segoe UI", "fontSize": 10, "fontColor": { "solid": { "color": "#353535" } }, "bold": true }],
"columnHeaders": [{ "fontFamily": "Segoe UI", "fontSize": 10, "fontColor": { "solid": { "color": "#FFFFFF" } }, "backColor": { "solid": { "color": "#008F72" } }, "bold": true }],
"grid": [{ "gridVertical": false, "gridHorizontal": true, "gridHorizontalColor": { "solid": { "color": "#E0E0E0" } } }]
}
}
}
}
dataColors[0] drives the first series in any chart (Actuals); dataColors[1] drives the second (Budget). That's how you avoid hand-writing per-visual colors.
pbi report get-theme returns the parsed JSON if Desktop sees it. If the JSON is returned but Desktop UI shows defaults, the name-matching from §7.1 is wrong — check all three names.
Cause: pywin32 not in pipx venv (Section 2), so Desktop's in-memory state never received the new page, and Ctrl+S overwrote disk with stale memory. Fix: install pywin32, have user close-without-save and reopen, redo the work. Now writes survive.
Same cause as above. Recreate the theme file, patch report.json, validate, handoff per §1.
Name mismatch (§7.1). The three names — customTheme.name, resourcePackages.items[].name, file basename — must be identical. Fix the names, validate, close-without-save, reopen.
pbi connect succeeds but pbi table list returns "No results"The auto-detect picked a server but no catalog. Run pbi --json database list for the GUID, then pbi connect -d localhost:<port> -C <full-guid>. If the catalog is genuinely empty (TMDL on disk has tables but model is empty), Desktop didn't load — close-without-save, reopen, reconnect.
Partition exists, columns don't. Run pbi column list -t <Table> — if only RowNumber shows, you skipped column creation. Add columns with pbi column create, then pbi table refresh <Table> --type Full.
Usually from hand-written TMDL. Don't try to fix the bad TMDL by hand. Replace model.tmdl with a minimal valid stub (just model Model block, no tables), have user open Desktop (will load empty), reconnect pbi-cli, recreate tables/columns/measures via pbi-cli commands, export-tmdl, handoff.
report.json schema error on file openFrom pbi report set-theme. Fix by hand (see §7.2): convert reportVersionAtImport from string to object, change type: 202 to type: "CustomTheme", fix path to drop BaseThemes/ prefix, set item name to match customTheme.name.
Visuals reference measure/column names by string. As long as the new model uses the same names, they reconnect on next file open. If renamed, update queryRef and Property fields in visual JSON to match. pbi report validate does NOT catch dead measure references — it checks JSON shape only. The errors only surface when Desktop renders the visual.
pbi report list-pages but commands fail with "Page not found"You ran pbi report add-page from outside the .pbip project root. The page name was registered with the live model (Desktop saw it briefly) but no folder was written to disk. Fix: cd into the project root, re-run pbi report add-page, redo any subsequent visual commands.
Auto-injected filterConfig with "type": "Advanced" filters and no defined conditions (see §6 — bind --value bug). Open the table's visual.json, delete the entire filterConfig block, validate, handoff. If it returns after a save cycle, the user may have an old-cached state — close-without-save and reopen first, then re-strip if the bug recurs.
.pbix Save As fails with a path errorDesktop's .pbix packager can't resolve the registered theme. Two causes:
path field in report.json lost its .json extension (Desktop strips it on saves). Fix: edit report.json, change "path": "MyTheme" to "path": "MyTheme.json".name, customTheme.name, and items[].name to a no-space form.The robust long-term fix is to migrate the theme to native import (§7 TL;DR). The .pbix packager handles native themes cleanly.
Desktop reformatted the theme registration on save — possibly stripped the .json extension, possibly injected a random suffix into the file name (e.g. Gaia_Herbs7709083932962756.json), possibly replaced the registered name. Inspect report.json against §7.1; align all three names; rename the on-disk file to match. If the user does Save As again, expect the same fix to be needed. Migrate to native theme import to break the cycle.
| Need | Command |
|---|---|
| Connect | pbi connect (or pbi connect -d localhost:<port> -C <guid>) |
| Inspect | pbi connections list, pbi --json database list, pbi table list, pbi column list -t T, pbi measure list, pbi visual list --page <id> |
| Run DAX | pbi dax execute "EVALUATE ..." |
| Create table | cat <<EOF | pbi table create Name --mode Import --m-expression - (pipe M via stdin) |
| Create column | pbi column create Name -t Table --data-type <type> --source-column Name |
| Create measure | pbi measure create "Name" -t Table -e "<DAX>" --format-string "..." |
| Refresh table | pbi table refresh Name --type Full (NOT Automatic) |
| Add page | pbi report add-page -d "Display" --width 1280 --height 720 |
| Delete page | pbi report delete-page <page-id> |
| Hide page | pbi report set-visibility <page-id> --hidden |
| Page background | pbi report set-background <page-id> -c "#FFFFFF" -t 0 |
| Add visual | pbi visual add --page <id> --type <type> -n <name> --x N --y N --width N --height N |
| Bind visual | pbi visual bind <name> --page <id> --category/--value/--row/--field/--legend "..." |
| Visual title | pbi visual set-container <name> --page <id> --title "..." |
| Apply theme | Drop JSON in RegisteredResources/<Name>.json + edit report.json by hand (§7) — DO NOT use pbi report set-theme |
| Validate | pbi report validate |
| Snapshot model | pbi database export-tmdl <SemanticModel>/definition |
| Tell Desktop to refresh data | pbi report reload (only refreshes data, NOT report definition) |
When the build is done, deliver this to the user verbatim — it makes the close/reopen ritual unmistakable:
Build complete. Important: Desktop is still showing its in-memory state from before these changes. To see the new pages/visuals/theme:
- In Desktop, close the file WITHOUT saving. File → Close, click Don't Save if prompted. (Saving now would overwrite the new work with Desktop's stale memory.)
- Reopen the .pbip file.
- After reopen, you can save normally. From this point on, Ctrl+S is safe.
If they have manual edits in Desktop they want to keep, add: "If you made manual edits in Desktop during this session that you want to keep, save those first, then close, then reopen. Mixing manual Desktop edits with pbi-cli writes in the same unsaved session is the failure mode that loses work."