← PBI Desk / API
Desk Tokens

Drive PBI Desk from your own code

PBI Desk is an independent review desk for one Power BI project export. You paste the text artifacts of a single project — the semantic model definition, and optionally the report layout and a Performance Analyzer capture — and you get back one structured review of it in one of five lanes. Everything the web app does is reachable over HTTP with a token, and this page documents the exact contract: the field names below are the ones the prompt and the app's own renderer agree on, not a paraphrase of them.

PBI Desk is not affiliated with, endorsed by, or connected to Microsoft, Power BI or Microsoft Fabric. It is an independent tool that reads text you paste and writes text back. It opens no .pbix file, connects to no workspace, runs no query and executes no DAX.

Derived from @github/power-bi-model-design-review, @github/power-bi-dax-optimization, @github/power-bi-performance-troubleshooting, @github/powerbi-modeling and @github/power-bi-report-design-consultation, in the github/awesome-copilot repository.

Base URL and the envelope

Every call goes to https://api.skillsafe.ai/v1/app-api and every reply is one of:

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}
codeHTTPwhat it means here
unauthorized401Missing, malformed or expired token, or a guest token used on /run. Reviews are metered and need a personal token — mint one on the token page.
payment_required402Balance below min_credits for this lane. Call /estimate first and compare its min_credits against the credits figure from /me.
validation_error400The run body was not a JSON object, or a field was the wrong type — project sent as an array, target_ms sent as a string, prescan_facts sent as a JSON string instead of an object.
rate_limited429Too many calls. Back off and retry with the same Idempotency-Key. Never tight-loop a poll.
not_found404No such job id, or a job id belonging to another subject. Also returned for a path that is not an app-api route — check for a stray trailing slash.
internal500The review failed server-side. Failed runs are not billed; retry with the same key.

The task field — read this before anything else about input

task is the router, and it comes first

The whole app is one system prompt with an explicit task router. Every run must carry a task field whose value is exactly one of "model", "dax", "perf", "tmdl" or "report". Omit it or misspell it and the model picks the closest lane for the material it was given, sets lane to what it picked and says so in verdict_reason — which is a fallback, not a feature. A reply never blends two lanes' bodies into one object.

The run body is the input object itself. There is no input wrapper and no X-App-Slug header. Wrapping the object returns 200 while hiding task and project from the model, so you get a confident review of nothing.

taskwhat it takeswhat it returnsverdicts
model a semantic model definition: TMDL, or model.bim / TMSL JSON. A report layout may ride along; it is used only for cross-reference. schema, tables[], relationships[], model_actions[] sound / needs-rework / not-a-model
dax the same model definition, read for its measure expressions. focus narrows it to the measures you care about. measures[], dax_notes[] optimised / partially-optimised / no-measures-found
perf a Performance Analyzer export or a pasted Performance Analyzer table, ideally concatenated with the model definition so each bottleneck can be traced to a measure. target_ms sets the budget. bottlenecks[], budget, next_captures[] within-budget / over-budget / no-capture
tmdl a model definition plus a statement of what you want fixed. focus is where you name the findings to author against. artifacts[], apply_steps[], validation[] ready-to-apply / needs-decision / not-authorable
report a PBIP report.json layout. The semantic model may ride along so a visual can be tied to the measure it binds to. pages[], visuals[], layout_notes[], accessibility ship-ready / needs-rework / no-report-layout

The rest of the run body

fieldtyperequirednotes
taskstringyesthe lane: model, dax, perf, tmdl or report.
projectstringyesthe pasted Power BI project text. TMDL, model.bim / TMSL JSON, a PBIP report.json, a Performance Analyzer export, or several of them concatenated in one string. One project per run — the review is about a single model.
contextstringnofree text the reviewer should know: storage mode, refresh cadence, who consumes the report, what is deliberate. Up to 6000 characters. It changes the answer.
prescan_factsobjectnofacts computed by the in-browser parser over the whole paste, before any clipping. Every flags[].id in it must come back exactly once in coverage_check. See below.
focusstringnowhich measures or findings the dax and tmdl lanes should work on. Up to 600 characters. Ignored by the other three lanes.
target_msnumbernothe perf lane's per-visual budget in milliseconds. Defaults to 2000. The lane echoes where the number came from in budget.note.
retry_notestringnoonly sent by the reformat-retry path. When present, the model treats its previous answer as having failed to parse, reads the note, and replies again with nothing but the single JSON object.

prescan_facts — optional, and it is what makes the reply accountable

The web app runs a real parser in the browser before it spends anything, and passes the result in. You do not have to send it. If you do, its counts override the model's own reading, and the shape that matters is:

{
  "kind": "tmdl",
  "counts": {"tables": 9, "columns": 74, "measures": 21, "relationships": 10,
             "roles": 1, "pages": 3, "visuals": 17, "captured_visuals": 6,
             "dax_findings": 14},
  "shape": "snowflake",
  "flags": [{"id": "P1", "severity": "blocker", "area": "relationships",
             "object": "Sales -> Customer", "what": "two active filter paths"}],
  "perf": {"target_ms": 2000, "worst_total_ms": 5310, "over_budget": 2},
  "tables": ["Sales", "Customer", "Date"],
  "measures": ["Total Sales", "Margin %"]
}

The contract on flags is exact, and it is the app's grounding rule. Every id you send must come back exactly once in coverage_check, with handling either "addressed" (the reviewer agrees and it is reflected in findings) or "set-aside" (the reviewer looked and disagrees, or it does not matter for this lane) and a one-line note. An id that is neither addressed nor set aside is a contract violation; an id that was never sent must not appear. The web app renders the difference as a reconciliation table, and step 6 shows how to check it yourself.

counts is measured over the whole paste. objects_seen in the reply is what the model could see in the text it actually received. When the paste was clipped the two disagree on purpose, and the gap is declared in assumptions rather than papered over.

One worked run body per lane

These are complete bodies. POST any of them verbatim to /estimate or /run; nothing else goes in the request but the headers.

task: "model" — is the schema defensible?

{
  "task": "model",
  "project": "createOrReplace\n\ntable Sales\n\tcolumn CustomerKey\n\t\tdataType: int64\n\t\tsummarizeBy: sum\n\tcolumn OrderDate\n\t\tdataType: dateTime\n\tmeasure 'Total Sales' = SUMX(Sales, Sales[Qty] * Sales[UnitPrice])\n\ntable Customer\n\tcolumn CustomerKey\n\t\tdataType: int64\n\ntable Region\n\tcolumn RegionKey\n\t\tdataType: int64\n\nrelationship Sales_Customer\n\tfromColumn: Sales.CustomerKey\n\ttoColumn: Customer.CustomerKey\n\tcrossFilteringBehavior: bothDirections\n\nrelationship Sales_Region_via_Store\n\tfromColumn: Sales.StoreKey\n\ttoColumn: Store.StoreKey\n",
  "context": "Import mode, refreshed hourly. RLS by sales region is live for about 400 users. The Store table was added last sprint.",
  "prescan_facts": {
    "kind": "tmdl",
    "counts": {"tables": 9, "columns": 74, "measures": 21, "relationships": 10,
               "roles": 1, "pages": 0, "visuals": 0, "captured_visuals": 0,
               "dax_findings": 14},
    "shape": "snowflake",
    "flags": [
      {"id": "P1", "severity": "blocker", "area": "relationships",
       "object": "Sales -> Customer", "what": "two active filter paths"},
      {"id": "P2", "severity": "high", "area": "rls",
       "object": "Customer", "what": "bidirectional relationship under an active role"}
    ],
    "tables": ["Sales", "Customer", "Region", "Store", "Date"],
    "measures": ["Total Sales", "Margin %"]
  }
}

task: "dax" — rewrite the measures

Every measure in the paste gets a row, including the ones the reviewer would not change. focus does not shrink that list; it says where to spend the attention.

{
  "task": "dax",
  "project": "measure 'Margin %' = DIVIDE([Margin], [Total Sales])\n\nmeasure 'Sales LY' = CALCULATE([Total Sales], FILTER(ALL('Date'), 'Date'[Year] = MAX('Date'[Year]) - 1))\n\nmeasure 'Active Customers' = DISTINCTCOUNT(Sales[CustomerKey])\n",
  "focus": "Sales LY and Active Customers - both sit on the executive page and both feel slow.",
  "context": "Date is marked as a date table. 40 million fact rows, import mode.",
  "prescan_facts": {
    "kind": "tmdl",
    "counts": {"tables": 9, "columns": 74, "measures": 21, "relationships": 10,
               "roles": 1, "pages": 0, "visuals": 0, "captured_visuals": 0,
               "dax_findings": 14},
    "shape": "snowflake",
    "flags": [
      {"id": "D3", "severity": "high", "area": "dax",
       "object": "[Sales LY]", "what": "FILTER over a whole table inside CALCULATE"}
    ],
    "tables": ["Sales", "Customer", "Date"],
    "measures": ["Total Sales", "Margin %", "Sales LY", "Active Customers"]
  }
}

task: "perf" — rank the bottlenecks against a budget

{
  "task": "perf",
  "project": "{\"events\":[{\"name\":\"Executive Summary / Sales by Region\",\"visualType\":\"clusteredBarChart\",\"dax_ms\":4180,\"render_ms\":900,\"other_ms\":230,\"total_ms\":5310}]}\n\n--- model ---\nmeasure 'Sales LY' = CALCULATE([Total Sales], FILTER(ALL('Date'), 'Date'[Year] = MAX('Date'[Year]) - 1))\n",
  "target_ms": 1500,
  "context": "Captured on a P1 capacity at 09:00, cold cache, no other users on the workspace.",
  "prescan_facts": {
    "kind": "perf-analyzer",
    "counts": {"tables": 9, "columns": 74, "measures": 21, "relationships": 10,
               "roles": 1, "pages": 3, "visuals": 17, "captured_visuals": 6,
               "dax_findings": 14},
    "shape": "snowflake",
    "flags": [
      {"id": "T1", "severity": "high", "area": "dax",
       "object": "Sales by Region", "what": "5310 ms total, DAX-dominant"}
    ],
    "perf": {"target_ms": 1500, "worst_total_ms": 5310, "over_budget": 2},
    "tables": ["Sales", "Customer", "Date"],
    "measures": ["Total Sales", "Sales LY"]
  }
}

task: "tmdl" — author the corrected definitions

This lane writes TMDL when the paste was TMDL and TMSL JSON when the paste was BIM — it matches the dialect it was given. Every artifact it emits must trace back to a finding id or a prescan_facts.flags id, which is what focus is for.

{
  "task": "tmdl",
  "project": "createOrReplace\n\ntable Date\n\tcolumn Date\n\t\tdataType: dateTime\n\ntable Sales\n\tmeasure 'Total Sales' = SUMX(Sales, Sales[Qty] * Sales[UnitPrice])\n\nrelationship Sales_Customer\n\tfromColumn: Sales.CustomerKey\n\ttoColumn: Customer.CustomerKey\n\tcrossFilteringBehavior: bothDirections\n",
  "focus": "Fix P1 and P2: break the second active path and make the Customer relationship single-direction. Also mark Date as the date table.",
  "context": "We can take a breaking change this sprint. Nothing downstream depends on bidirectional filtering.",
  "prescan_facts": {
    "kind": "tmdl",
    "counts": {"tables": 9, "columns": 74, "measures": 21, "relationships": 10,
               "roles": 1, "pages": 0, "visuals": 0, "captured_visuals": 0,
               "dax_findings": 14},
    "shape": "snowflake",
    "flags": [
      {"id": "P1", "severity": "blocker", "area": "relationships",
       "object": "Sales -> Customer", "what": "two active filter paths"},
      {"id": "P2", "severity": "high", "area": "rls",
       "object": "Customer", "what": "bidirectional relationship under an active role"}
    ],
    "tables": ["Sales", "Customer", "Date"],
    "measures": ["Total Sales"]
  }
}

task: "report" — pages, chart choice and accessibility

{
  "task": "report",
  "project": "{\"sections\":[{\"displayName\":\"Executive Summary\",\"visualContainers\":[{\"config\":\"{\\\"name\\\":\\\"a1b2\\\",\\\"singleVisual\\\":{\\\"visualType\\\":\\\"pieChart\\\"}}\"}]}]}",
  "context": "Viewed on a 1280x720 projector in a monthly review. Two of the readers are colour-vision deficient.",
  "prescan_facts": {
    "kind": "report-json",
    "counts": {"tables": 0, "columns": 0, "measures": 0, "relationships": 0,
               "roles": 0, "pages": 3, "visuals": 17, "captured_visuals": 0,
               "dax_findings": 0},
    "shape": "ambiguous",
    "flags": [
      {"id": "R4", "severity": "medium", "area": "layout",
       "object": "Executive Summary", "what": "11 visuals on one page"}
    ],
    "pages": ["Executive Summary", "Detail", "Appendix"]
  }
}

retry_note is the seventh key and you will rarely write it by hand. Resend the same body with, for example, "retry_note": "the previous reply was wrapped in a code fence and lost its closing brace", and the reviewer replies again with only the single JSON object — same lane, every envelope field present, every array present even when empty.

Step 0 — a tiny client

Six endpoints, one envelope, one bearer token. Everything below builds on this one helper, so paste it once and the remaining steps are two or three lines each. Pick your language on the tabs; the choice sticks for every code group on the page.

# One base URL, one header pair. Put the token in the shell once.
API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"

ss() {   # ss /me            -> GET
         # ss /estimate FILE -> POST the file as the body
  if [ -n "$2" ]; then
    curl -s -X POST "$API$1" \
      -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
      -H "Content-Type: application/json" \
      -d @"$2"
  else
    curl -s "$API$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
  fi
}

Step 1 — get a token

A guest token is minted on demand and is enough for the two free calls, /me and /estimate. Running a review is metered and needs a personal token: open the token page, sign in, and press the copy button — it hands you a ready-made shell export. The token lives in that browser's own storage for this origin; nothing on this page transmits it anywhere but the SkillSafe API.

# A guest token - free calls only (/me and /estimate)
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"pbi-desk"}'

# The reply carries {"ok":true,"data":{"token":"aut_..."}}.
#
# Reviewing a project is metered and needs a PERSONAL token. Open
#   https://pbi-desk.skillsafe.ai/tokens.html
# sign in there, and copy the shell export it gives you:
export SKILLSAFE_TOKEN="YOUR_TOKEN"

Step 2 — GET /me, the balance and the subject type

Free. Returns subject_type (user or guest), the username and the credit balance. Check it before you submit: a guest token estimates happily and then fails the run, and a balance under this lane's min_credits turns into a payment_required after you have already built the body.

curl -s "https://api.skillsafe.ai/v1/app-api/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"

# {"ok":true,"data":{"subject_type":"user","username":"...","credits":128400}}
# subject_type "guest" means /estimate works but /run will be rejected.

Step 3 — POST /estimate, which is free

Creates no job and costs nothing. It returns model, model_alias, markup_bps, hold_credits and min_credits. hold_credits is a reservation, not a price. It is priced against the full output cap; you are charged only for what the review actually uses, which is usually far less, and the settled figure comes back as charged_credits. Never quote the hold to a user as the cost.

Re-estimate on every lane switch. The five lanes load different prompt sections and produce very differently sized bodies — a tmdl run that authors whole table definitions reserves a good deal more than a perf run that ranks six captured visuals — so their holds differ over the same project string.

A clean estimate is not evidence the input is right. /estimate performs no body validation whatsoever: a bare string, a number and null all come back as well-formed estimates with the correct model binding. It proves the app is wired to the right model at the right markup, and it proves nothing at all about your input shape. Always send the object you intend to run, and treat the first /run as the first real check of the body.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"model","project":"table Sales\n  column CustomerKey\n","context":"Import mode."}'

# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                    "markup_bps":1000,"hold_credits":5240,"min_credits":900}}
#
# Re-run this for each task value you intend to use. The holds are not the same.

Step 4 — POST /run, then poll GET /jobs/{job_id}

Returns {"job_id": "..."} immediately. Poll until status leaves running; the reply text sits at data.output.output and is the single JSON object documented below. Pass an Idempotency-Key on every run, and put the lane in the key — reviewing the same project as model and then as dax is two distinct runs, and a key without the lane would hand the second one the first one's answer. A retry after a network blip must reuse the same key, or the transport error double-bills.

# The Idempotency-Key must carry the LANE. Five lanes over one project are five
# distinct runs and must not collide on one key.
KEY="pbi-desk:model:$(shasum -a 256 run-input.json | cut -c1-32):a1"

JOB=$(curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @run-input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

until [ "$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
       | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')" != "running" ]; do
  sleep 2
done

curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

Step 5 — or stream it with POST /run-stream

Server-Sent Events, same body and same Idempotency-Key. Three event types: job once at the start, delta repeatedly carrying a text chunk, and done at the end with status, charged_credits and truncated. Concatenate every delta and parse the concatenation — a single delta is a fragment of JSON and never valid on its own. If the stream dies mid-flight, keep whatever parses rather than discarding it: the run was billed either way, and a tmdl reply that lost its last artifact is still worth reading.

curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: $KEY" \
  -d @run-input.json

# event: job     {"job_id":"..."}
# event: delta   {"text":"{\"lane\":\"model\",\"title\":\"Sales"}
# event: done    {"status":"succeeded","charged_credits":1840,"truncated":false}

Step 6 — parsing the reply, and checking the grounding contract

The reply is one JSON object and nothing else. In practice two things go wrong often enough to be worth guarding: a model occasionally wraps the object in a code fence, and a stream that was cut short leaves a trailing fragment. Strip a leading fence, then take the outermost { … } — that survives both.

Then run the grounding check. Every id you sent in prescan_facts.flags must appear exactly once in coverage_check, and no id you did not send may appear at all. A missing id means a parser-detected defect was silently dropped, which is the one failure mode that makes a review untrustworthy. When it fails, resend the same body with a retry_note naming the missing ids.

# data.output.output is a STRING holding the JSON object. Strip an accidental
# code fence, keep the outermost { ... }, then check the coverage contract.
curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | python3 -c '
import sys, json
raw = json.load(sys.stdin)["data"]["output"]["output"].strip()
if raw.startswith("```"):
    raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0]
reply = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])

sent = [f["id"] for f in json.load(open("run-input.json"))
        .get("prescan_facts", {}).get("flags", [])]
got = [c["id"] for c in reply["coverage_check"]]
print(reply["lane"], reply["verdict"], "-", reply["headline"])
print("uncovered flags:", [i for i in sent if got.count(i) != 1] or "none")
print("unsent ids returned:", [i for i in got if i not in sent] or "none")
'

The output contract

One JSON object, no prose around it, no code fence. Every field below is required in every lane, and every array is required even when it is empty — an empty quick_wins is [], never a missing key and never null. The fields are plain text: no markdown, no bold markers, no emoji. Code appears only in the fields designated for it (original, rewritten, tmdl).

The envelope, identical in all five lanes

fieldtypenotes
lanestringequals the task you asked for. A reply that disagrees means the router fell back; read verdict_reason to find out why.
titlestring90 characters or fewer, naming the model or report and what was done to it.
datasetstringthe semantic model or report name taken from the input, or the literal "unnamed".
verdictstringone of this lane's three values. See the lane tables below.
verdict_reasonstringone or two sentences on why that verdict and not the neighbouring one.
headlinestring220 characters or fewer — the one sentence a reader would quote.
input_kindstringtmdl bim report-json perf-analyzer mixed prose unknown. The reviewer's own judgement, which may disagree with prescan_facts.kind.
objects_seenobject{tables, measures, relationships, pages, visuals, captured_visuals}, all numbers. Counts what the reviewer could see in the text it received. A gap against prescan_facts.counts is expected when the paste was clipped and is declared in assumptions, not silently corrected.
findings[]array{id, severity, area, object, what, why, fix, effort}. Ids are F1, F2, F3 in emission order with no gaps. severity is blocker / high / medium / low / info and means consequence, not effort. area is one of schema relationships dax storage rls refresh performance layout accessibility naming metadata. effort is minutes / hours / days. Ordered blocker first.
coverage_check[]array{id, handling, note}, where id is a prescan_facts.flags id and handling is addressed or set-aside. Exactly one row per id you sent, and no rows for ids you did not send.
assumptions[]array{assumption, risk_if_wrong}. This is where "the paste had no partitions so storage mode is unknown" belongs.
open_questions[]string[]what the reviewer would need to see to go further. Anything not present in the paste is discussed here, never in findings.
quick_wins[]string[]changes worth minutes that pay back immediately.
summarystringthree to six sentences a reviewer could paste into a pull request.

model lane body

Verdicts: sound — a defensible star, nothing in it will mislead a report author. needs-rework — it works, but at least one relationship, storage mode or table role will produce wrong or slow answers. not-a-model — the paste is not a semantic model definition; the reply says what it looks like instead and stops.

fieldtypenotes
schemaobject{shape, shape_reason, fact_tables[], dimension_tables[], unclassified[]}. shape is star / snowflake / galaxy / flat / ambiguous; shape_reason names the tables that make it that shape.
tables[]array{name, role, storage_mode, column_count, measure_count, keep, issues[]}. role is fact / dimension / bridge / calculation / helper / unknown; storage_mode is import / directquery / dual / unknown; keep is as-is / change / remove.
relationships[]array{from, to, cardinality, cross_filter, active, verdict, note}. Endpoints are Table[Column]. cardinality is many-to-one / one-to-many / one-to-one / many-to-many / unknown; cross_filter is single / both / none / unknown; active is a boolean; verdict is ok / risky / wrong.
model_actions[]array{order, action, object, impact} — the ordered plan: what to change first and what each change buys.

The lane leads with ambiguous filter paths: two or more active paths between the same pair of tables, or a cycle in the active relationship graph. That is the defect that silently produces wrong totals, and it outranks every performance concern. After it come bidirectional cross-filtering outside a designed bridge (worse still under RLS, where it leaks rows), many-to-many where a dimension would do, fact-to-fact relationships, snowflaking, disconnected tables, storage-mode mismatches, auto date/time tables, high-cardinality columns left visible, and metadata hygiene.

dax lane body

Verdicts: optimised — every measure is now in the form the reviewer would ship. partially-optimised — some improved, some left alone, and verdict_reason says which and why. no-measures-found — there are no DAX expressions in the input.

fieldtypenotes
measures[]array{name, table, original, rewritten, pattern, why, expected_gain, behaviour_change, behaviour_note, verify}.
dax_notes[]string[]cross-cutting observations that are not about one measure.
measures[] fieldnotes
name, tablethe measure name exactly as it appears, and its home table or "unknown".
originalverbatim from the input, whitespace aside. If the prescan says the body was clipped, the row still appears, with rewritten: "" and pattern saying it was not reviewed.
rewrittenthe reviewer's version, or the empty string when it is leaving the measure alone.
patternthe named anti-pattern, for example "FILTER over a whole table inside CALCULATE".
whywhy the rewrite is faster or clearer, in engine terms.
expected_gainlarge / moderate / small / readability-only / none.
behaviour_changenone / possible / yes. The field that matters most. Swapping / for DIVIDE turns a divide-by-zero error into a blank; swapping ALL for REMOVEFILTERS does not.
behaviour_notewhat could return a different number, or the empty string when nothing can.
verifythe concrete check that proves the rewrite is equivalent.

Every measure in the input gets a row, including the untouched ones. A measure left alone carries rewritten: "", expected_gain: "none" and a why that says what already makes it correct. Silently dropping a measure is a contract violation, so compare measures.length against prescan_facts.counts.measures before you trust the reply.

perf lane body

Verdicts: within-budget — every captured visual lands under the target. over-budget — at least one visual is over and the cause is named. no-capture — there is no Performance Analyzer data in the input; the reply says what to capture and stops.

fieldtypenotes
bottlenecks[]array{rank, visual, page, visual_type, dax_ms, render_ms, other_ms, total_ms, category, cause, fix, expected_saving_ms}. Ranked by total_ms descending, rank consecutive from 1. category is dax / model / visual / refresh / gateway / unknown.
budgetobject{target_ms, worst_total_ms, over_budget_count, note}. note is one line on where the target came from — your target_ms, a number named in context, or the 2000 ms default.
next_captures[]array{what, how, why} — the captures to take before the next round.

Every millisecond figure is copied, never estimated. dax_ms, render_ms, other_ms and total_ms come from the capture or from prescan_facts.perf. expected_saving_ms is the single number the reviewer may reason to, and when category is dax it must be smaller than that visual's dax_ms — a cheap assertion worth running on your side. category is a diagnosis: DAX-dominant points at the measure, render-dominant at the visual (too many data points, a table of thousands of rows, a custom visual), and an other_ms-dominant visual usually means the query queue or the gateway.

tmdl lane body

Verdicts: ready-to-apply — every artifact can be pasted into the project as-is. needs-decision — at least one artifact depends on a choice only you can make, and it is named. not-authorable — there is not enough model definition in the input to write against.

fieldtypenotes
artifacts[]array{path, kind, name, tmdl, purpose, replaces}. path is a project-relative file path such as definition/tables/Date.tmdl. kind is table / column / measure / relationship / role / hierarchy / expression / annotation. replaces is the object it supersedes, or the empty string when it is new.
apply_steps[]array{order, action, where, expect} — where to paste it and what you should see afterwards.
validation[]array{check, status, evidence}, status being pass / fail / n/a. The reviewer's own check of what it just wrote: does every referenced column exist in the pasted model, is every measure name unique, does every relationship endpoint exist. A fail row is honest and useful.

The tmdl field is indented with spaces and contains no tab characters — TMDL is indentation-sensitive and the app rejects a tab in the emitted text. The lane matches the dialect it was given: TMDL in, TMDL out; BIM JSON in, TMSL JSON out. Every authored measure carries a formatString, a displayFolder and a description; every authored relationship names fromColumn, toColumn, crossFilteringBehavior and whether it is active. An artifact whose purpose references neither a finding id nor a prescan_facts.flags id is scope creep and should not be there.

report lane body

Verdicts: ship-ready — the pages read clearly and nothing in them misleads. needs-rework — at least one visual is the wrong chart for its question, or the page cannot be read by someone using a screen reader or a colour-vision-deficient palette. no-report-layout — there is no report layout in the input; the reply says what to paste and stops.

fieldtypenotes
pages[]array{name, visual_count, verdict, issues[]}, page verdict being ok / busy / needs-rework. issues is [] when the page is fine.
visuals[]array{id, page, current_type, suggested_type, decision, reason, accessibility[]}. decision is keep / change / merge / remove / rebind, where rebind is the right mark bound to the wrong field and keeps suggested_type equal to current_type; suggested_type repeats current_type when the mark is right. reason states the question the visual answers before it names the mark.
layout_notes[]string[]grid, alignment, reading order, page count, navigation.
accessibilityobject{summary, checks[]}, each check {check, status, note} with status pass / fail / unknown. A layout that does not carry a palette or a font size yields unknown, never fail.

Chart choice is answered by the question, not by taste: composition over time is a stacked area or a line per series, part-to-whole at one instant is a bar (a pie only below about five categories), ranking is a sorted bar, correlation is a scatter, distribution is a histogram or box plot. Accessibility checks are concrete — colour as the only encoding, a palette that collapses under deuteranopia, text under about 9pt, a table with no header row, missing alt text, a KPI whose direction is signalled only by red and green, a reading order that does not follow the visual order. More than about eight visuals on one page needs a reason.

Input shapes the parser understands

project is one string. The in-browser parser sniffs it, and you can concatenate several artifacts into that one string — a model definition followed by a Performance Analyzer export is the usual perf submission, because it lets a bottleneck be traced back to the measure that explains it. When you concatenate, put a plain separator line between the parts so the sniffing stays honest; input_kind then comes back as mixed.

TMDL

The PBIP text format, one file per object under definition/. Recognised by its table / column / measure / relationship blocks and its indentation.

createOrReplace

table Sales
	lineageTag: 6f1c...
	column CustomerKey
		dataType: int64
		summarizeBy: sum
	measure 'Total Sales' = SUMX(Sales, Sales[Qty] * Sales[UnitPrice])
		formatString: #,0

relationship Sales_Customer
	fromColumn: Sales.CustomerKey
	toColumn: Customer.CustomerKey
	crossFilteringBehavior: bothDirections

model.bim / TMSL JSON

The older single-file model definition. Recognised by a top-level model object holding tables and relationships. Paste it whole; the tmdl lane will answer in TMSL to match.

{
  "name": "SemanticModel",
  "compatibilityLevel": 1567,
  "model": {
    "tables": [
      {"name": "Sales",
       "columns": [{"name": "CustomerKey", "dataType": "int64", "summarizeBy": "sum"}],
       "measures": [{"name": "Total Sales", "expression": "SUMX(Sales, Sales[Qty] * Sales[UnitPrice])"}],
       "partitions": [{"name": "Sales", "mode": "import"}]}
    ],
    "relationships": [
      {"name": "Sales_Customer", "fromTable": "Sales", "fromColumn": "CustomerKey",
       "toTable": "Customer", "toColumn": "CustomerKey", "crossFilteringBehavior": "bothDirections"}
    ]
  }
}

PBIP report.json — both layouts

The legacy form is one file with sections[], each carrying visualContainers[] whose config is itself a JSON string. The newer PBIR form splits the report into a folder per page with a visual.json per visual. Both are read; paste either the single file or the concatenated per-visual files.

// legacy: report.json
{"sections": [
  {"displayName": "Executive Summary", "ordinal": 0,
   "visualContainers": [
     {"x": 0, "y": 0, "width": 480, "height": 320,
      "config": "{\"name\":\"a1b2\",\"singleVisual\":{\"visualType\":\"pieChart\"}}"}
   ]}
]}

// newer PBIR: definition/pages/<page>/visuals/<id>/visual.json
{"name": "a1b2",
 "position": {"x": 0, "y": 0, "width": 480, "height": 320},
 "visual": {"visualType": "pieChart",
            "query": {"queryState": {"Values": {"projections": [{"field": {"Measure": {"Property": "Total Sales"}}}]}}}}}

Performance Analyzer

Either the JSON export — an object with an events array — or the table you get from copying the Performance Analyzer pane. Both are read; the JSON export is better, because the pasted table loses the visual type. Whatever you send, the numbers in bottlenecks[] are copied from it and never invented.

// the JSON export
{"events": [
  {"name": "Sales by Region", "visualType": "clusteredBarChart", "page": "Executive Summary",
   "dax_ms": 4180, "render_ms": 900, "other_ms": 230, "total_ms": 5310},
  {"name": "Margin KPI", "visualType": "card", "page": "Executive Summary",
   "dax_ms": 120, "render_ms": 60, "other_ms": 20, "total_ms": 200}
]}

// or the pasted pane, tab or space separated
Visual                DAX query   Visual display   Other   Total
Sales by Region       4180        900              230     5310
Margin KPI            120         60               20      200

Anything the parser cannot classify still runs — input_kind comes back as prose or unknown, the lane's third verdict fires (not-a-model, no-measures-found, no-capture, not-authorable, no-report-layout), and the reply says what to paste instead rather than guessing at a model it never saw.

Metering, in one place

What this is not

PBI Desk reads text and writes text. It opens no .pbix file, connects to no workspace or Fabric capacity, issues no DAX query, and measures nothing itself — every timing it reports was measured by Power BI and pasted in by you. It will not name a table, column, measure, page, visual or duration that is not in your input; anything it wants to discuss but cannot see goes into open_questions. And it is an independent review desk: not affiliated with, endorsed by, or connected to Microsoft, Power BI or Microsoft Fabric.