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": { ... }}}
| code | HTTP | what it means here |
|---|---|---|
unauthorized | 401 | Missing, 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_required | 402 | Balance below min_credits for this lane. Call /estimate first and compare its min_credits against the credits figure from /me. |
validation_error | 400 | The 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_limited | 429 | Too many calls. Back off and retry with the same Idempotency-Key. Never tight-loop a poll. |
not_found | 404 | No 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. |
internal | 500 | The 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.
task | what it takes | what it returns | verdicts |
|---|---|---|---|
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
| field | type | required | notes |
|---|---|---|---|
task | string | yes | the lane: model, dax, perf, tmdl or report. |
project | string | yes | the 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. |
context | string | no | free 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_facts | object | no | facts 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. |
focus | string | no | which measures or findings the dax and tmdl lanes should work on. Up to 600 characters. Ignored by the other three lanes. |
target_ms | number | no | the perf lane's per-visual budget in milliseconds. Defaults to 2000. The lane echoes where the number came from in budget.note. |
retry_note | string | no | only 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
}
import json, os, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # /tokens.html
def call(path, body=None, token=TOKEN, extra_headers=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
headers.update(extra_headers or {})
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, headers=headers,
method="POST" if data is not None else "GET")
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read().decode())
if not payload.get("ok"):
raise RuntimeError(payload.get("error"))
return payload["data"]
const API = "https://api.skillsafe.ai/v1/app-api";
var TOKEN = "YOUR_TOKEN"; // from https://pbi-desk.skillsafe.ai/tokens.html
async function call(path, body, opts = {}) {
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (opts.token !== null) headers.Authorization = `Bearer ${opts.token ?? TOKEN}`;
const res = await fetch(API + path, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!payload.ok) throw Object.assign(new Error(payload.error?.message), payload.error);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const api = "https://api.skillsafe.ai/v1/app-api"
func token() string {
if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN" // from https://pbi-desk.skillsafe.ai/tokens.html
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error json.RawMessage `json:"error"`
}
func call(path string, body any, hdr map[string]string) (json.RawMessage, error) {
var rdr io.Reader
method := http.MethodGet
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = http.MethodPost
}
req, _ := http.NewRequest(method, api+path, rdr)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token())
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var e envelope
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
return nil, err
}
if !e.OK {
return nil, fmt.Errorf("%s", e.Error)
}
return e.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class PbiDesk {
static final String API = "https://api.skillsafe.ai/v1/app-api";
// from https://pbi-desk.skillsafe.ai/tokens.html
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody, Map<String, String> extra) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN);
extra.forEach(b::header);
HttpRequest req = jsonBody == null
? b.GET().build()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody)).build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} or {"ok":false,"error":{...}}
}
}
require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
# from https://pbi-desk.skillsafe.ai/tokens.html
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body = nil, extra = {})
uri = URI(API + path)
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
extra.each { |k, v| req[k] = v }
req.body = JSON.dump(body) unless body.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"].to_s unless payload["ok"]
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
// from https://pbi-desk.skillsafe.ai/tokens.html
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function call(string $path, $body = null, array $extra = []) {
global $TOKEN;
$headers = ["Content-Type: application/json", "Authorization: Bearer " . $TOKEN];
foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) { throw new RuntimeException(json_encode($payload["error"])); }
return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class PbiDesk {
const string Api = "https://api.skillsafe.ai/v1/app-api";
// from https://pbi-desk.skillsafe.ai/tokens.html
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> CallAsync(
string path, object? body = null, IDictionary<string, string>? extra = null) {
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, Api + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (extra is not null) foreach (var kv in extra) req.Headers.Add(kv.Key, kv.Value);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean())
throw new Exception(doc.RootElement.GetProperty("error").ToString());
return doc.RootElement.GetProperty("data");
}
}
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"
# Free calls only - /me and /estimate:
guest = call("/guest", {"slug": "pbi-desk"}, token=None)["token"]
# A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
# Keep TOKEN out of source control; the helper above already reads
# SKILLSAFE_TOKEN from the environment and falls back to the placeholder.
print(guest[:8] + "...", "guest token minted")
// Free calls only - /me and /estimate:
const { token: guest } = await call("/guest", { slug: "pbi-desk" }, { token: null });
// A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
// In a browser build, read it from your own settings store and assign it:
TOKEN = guest; // fine for /me and /estimate
// TOKEN = "aut_your_personal_token"; // required before /run
// Free calls only - /me and /estimate. /guest takes no Authorization header,
// so it is the one call that ignores token().
body, _ := json.Marshal(map[string]string{"slug": "pbi-desk"})
res, _ := http.Post(api+"/guest", "application/json", bytes.NewReader(body))
defer res.Body.Close()
var g struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&g)
fmt.Println("guest token:", g.Data.Token[:8]+"...")
// A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
// exported as SKILLSAFE_TOKEN.
// Free calls only - /me and /estimate:
HttpRequest guestReq = HttpRequest.newBuilder(URI.create(API + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"pbi-desk\"}"))
.build();
System.out.println(HTTP.send(guestReq, HttpResponse.BodyHandlers.ofString()).body());
// A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
// exported as SKILLSAFE_TOKEN, which TOKEN above already picks up.
# Free calls only - /me and /estimate:
uri = URI(API + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "pbi-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
guest = JSON.parse(res.body)["data"]["token"]
puts "guest token: #{guest[0, 8]}..."
# A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
# exported as SKILLSAFE_TOKEN.
<?php
// Free calls only - /me and /estimate:
$ch = curl_init(API . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "pbi-desk"]),
]);
$guest = json_decode(curl_exec($ch), true)["data"]["token"];
curl_close($ch);
printf("guest token: %s...\n", substr($guest, 0, 8));
// A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
// exported as SKILLSAFE_TOKEN.
// Free calls only - /me and /estimate:
using var guestRes = await new HttpClient().PostAsync(
"https://api.skillsafe.ai/v1/app-api/guest",
new StringContent("{\"slug\":\"pbi-desk\"}", Encoding.UTF8, "application/json"));
var guest = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("token").GetString();
Console.WriteLine($"guest token: {guest![..8]}...");
// A review needs a personal token from https://pbi-desk.skillsafe.ai/tokens.html
// exported as SKILLSAFE_TOKEN, which Token above already picks up.
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.
me = call("/me")
print(me["subject_type"], me.get("credits"))
if me["subject_type"] != "user":
raise SystemExit("reviewing a project needs a personal token")
const me = await call("/me");
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") throw new Error("reviewing a project needs a personal token");
raw, err := call("/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
if me.SubjectType != "user" {
panic("reviewing a project needs a personal token")
}
String me = call("/me", null, Map.of());
System.out.println(me); // subject_type must be "user" before /run
me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"
abort "reviewing a project needs a personal token" unless me["subject_type"] == "user"
$me = call("/me");
printf("%s %d\n", $me["subject_type"], $me["credits"] ?? 0);
if ($me["subject_type"] !== "user") {
throw new RuntimeException("reviewing a project needs a personal token");
}
var me = await PbiDesk.CallAsync("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
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.
run_input = {
"task": "model", # model | dax | perf | tmdl | report
"project": open("definition.tmdl").read(),
"context": "Import mode, refreshed hourly. RLS by region is live.",
# "focus": "Sales LY and Active Customers", # dax and tmdl lanes
# "target_ms": 1500, # perf lane
"prescan_facts": prescan, # optional, see above
}
est = call("/estimate", run_input)
print(est["hold_credits"], "credits reserved;", est["model"], est["model_alias"])
# FREE, creates no job, and performs no body validation at all - it cannot tell
# you the input shape is wrong, only that the model binding is right.
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up before running")
const runInput = {
task: "model", // model | dax | perf | tmdl | report
project: tmdlSource,
context: "Import mode, refreshed hourly. RLS by region is live.",
// focus: "Sales LY and Active Customers", // dax and tmdl lanes
// target_ms: 1500, // perf lane
prescan_facts: prescan, // optional
};
const est = await call("/estimate", runInput);
console.log(est.hold_credits, "reserved;", est.model, est.model_alias, est.markup_bps);
if (me.credits < est.min_credits) throw new Error("top up before running");
runInput := map[string]any{
"task": "model", // model | dax | perf | tmdl | report
"project": tmdlSource,
"context": "Import mode, refreshed hourly. RLS by region is live.",
}
raw, _ := call("/estimate", runInput, nil)
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
Hold int `json:"hold_credits"`
Min int `json:"min_credits"`
MarkupBps int `json:"markup_bps"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.Hold, "reserved;", est.Model, est.ModelAlias, est.MarkupBps)
String body = """
{"task":"model",
"project":"table Sales\\n column CustomerKey\\n",
"context":"Import mode, refreshed hourly."}
""";
System.out.println(call("/estimate", body, Map.of()));
// hold_credits is a reservation priced against the output cap, not a price.
run_input = {
"task" => "model", # model | dax | perf | tmdl | report
"project" => File.read("definition.tmdl"),
"context" => "Import mode, refreshed hourly. RLS by region is live.",
# "focus" => "Sales LY", # dax and tmdl lanes
# "target_ms" => 1500, # perf lane
}
est = call("/estimate", run_input)
puts "#{est["hold_credits"]} reserved; #{est["model"]} (#{est["model_alias"]})"
abort "top up before running" if me["credits"].to_i < est["min_credits"].to_i
$runInput = [
"task" => "model", // model | dax | perf | tmdl | report
"project" => file_get_contents("definition.tmdl"),
"context" => "Import mode, refreshed hourly. RLS by region is live.",
];
$est = call("/estimate", $runInput);
printf("%d reserved; %s (%s)\n", $est["hold_credits"], $est["model"], $est["model_alias"]);
if (($me["credits"] ?? 0) < $est["min_credits"]) {
throw new RuntimeException("top up before running");
}
var runInput = new Dictionary<string, object> {
["task"] = "model", // model | dax | perf | tmdl | report
["project"] = File.ReadAllText("definition.tmdl"),
["context"] = "Import mode, refreshed hourly. RLS by region is live.",
};
var est = await PbiDesk.CallAsync("/estimate", runInput);
Console.WriteLine($"{est.GetProperty("hold_credits")} reserved; {est.GetProperty("model")}");
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"])'
import hashlib, time
basis = "|~|".join([run_input["task"], run_input["project"],
run_input.get("context", ""), run_input.get("focus", ""),
str(run_input.get("target_ms", ""))])
key = "pbi-desk:{}:{}:a1".format(run_input["task"],
hashlib.sha256(basis.encode()).hexdigest()[:32])
job = call("/run", run_input, extra_headers={"Idempotency-Key": key})
job_id = job["job_id"]
while True:
j = call("/jobs/" + job_id)
if j["status"] != "running":
break
time.sleep(2)
result = json.loads(j["output"]["output"]) # the single JSON object
print(result["lane"], result["verdict"], len(result["findings"]), "findings")
const enc = new TextEncoder().encode([
runInput.task, runInput.project, runInput.context ?? "",
runInput.focus ?? "", String(runInput.target_ms ?? ""),
].join("|~|"));
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", enc))]
.map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
const key = `pbi-desk:${runInput.task}:${digest}:a1`;
const { job_id } = await call("/run", runInput, { headers: { "Idempotency-Key": key } });
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`/jobs/${job_id}`);
} while (job.status === "running");
const result = JSON.parse(job.output.output);
console.log(result.lane, result.verdict, result.findings.length, "findings");
sum := sha256.Sum256([]byte(strings.Join([]string{
"model", tmdlSource, contextText, "", "",
}, "|~|")))
key := fmt.Sprintf("pbi-desk:model:%x:a1", sum[:16])
raw, _ := call("/run", runInput, map[string]string{"Idempotency-Key": key})
var started struct {
JobID string `json:"job_id"`
}
json.Unmarshal(raw, &started)
for {
raw, _ = call("/jobs/"+started.JobID, nil, nil)
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(raw, &job)
if job.Status != "running" {
fmt.Println(job.Output.Output)
break
}
time.Sleep(2 * time.Second)
}
String key = "pbi-desk:model:" + Integer.toHexString(bodyJson.hashCode()) + ":a1";
String started = call("/run", bodyJson, Map.of("Idempotency-Key", key));
// Parse job_id out of `started`, then poll GET /jobs/{job_id} until
// status != "running", and read data.output.output as the single JSON object.
// Sleep two seconds between polls; a tight loop earns a 429.
require "digest"
basis = [run_input["task"], run_input["project"], run_input["context"].to_s,
run_input["focus"].to_s, run_input["target_ms"].to_s].join("|~|")
key = "pbi-desk:#{run_input["task"]}:#{Digest::SHA256.hexdigest(basis)[0, 32]}:a1"
job_id = call("/run", run_input, { "Idempotency-Key" => key })["job_id"]
loop do
job = call("/jobs/#{job_id}")
break puts(job["output"]["output"]) if job["status"] != "running"
sleep 2
end
$basis = implode("|~|", [
$runInput["task"], $runInput["project"], $runInput["context"] ?? "",
$runInput["focus"] ?? "", (string)($runInput["target_ms"] ?? ""),
]);
$key = sprintf("pbi-desk:%s:%s:a1", $runInput["task"], substr(hash("sha256", $basis), 0, 32));
$jobId = call("/run", $runInput, ["Idempotency-Key" => $key])["job_id"];
do {
sleep(2);
$job = call("/jobs/" . $jobId);
} while ($job["status"] === "running");
echo $job["output"]["output"];
var basis = string.Join("|~|", new[] {
"model", tmdlSource, contextText, "", "" });
var digest = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(basis)))[..32].ToLower();
var key = $"pbi-desk:model:{digest}:a1";
var started = await PbiDesk.CallAsync("/run", runInput,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
do {
await Task.Delay(2000);
job = await PbiDesk.CallAsync($"/jobs/{jobId}");
} while (job.GetProperty("status").GetString() == "running");
Console.WriteLine(job.GetProperty("output").GetProperty("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}
req = urllib.request.Request(
API + "/run-stream",
data=json.dumps(run_input).encode(),
headers={"Content-Type": "application/json", "Accept": "text/event-stream",
"Authorization": "Bearer " + TOKEN, "Idempotency-Key": key},
)
buf, event = "", None
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
buf += json.loads(line[6:]).get("text", "")
result = json.loads(buf[buf.index("{"):buf.rindex("}") + 1])
print(result["lane"], result["verdict"], result["headline"])
const res = await fetch(`${API}/run-stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": key,
},
body: JSON.stringify(runInput),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", pending = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
buf += JSON.parse(line.slice(6)).text ?? "";
}
}
}
const result = JSON.parse(buf.slice(buf.indexOf("{"), buf.lastIndexOf("}") + 1));
console.log(result.lane, result.verdict, result.headline);
req, _ := http.NewRequest(http.MethodPost, api+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var buf strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
buf.WriteString(d.Text)
}
}
fmt.Println(buf.String())
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(bodyJson))
.build();
StringBuilder buf = new StringBuilder();
final String[] event = { null };
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
buf.append(extractText(line.substring(6))); // your JSON reader
}
});
System.out.println(buf);
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key
req.body = JSON.dump(run_input)
buf = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
buf << (JSON.parse(line[6..])["text"] || "")
end
end
end
end
end
puts JSON.parse(buf[buf.index("{")..buf.rindex("}")])["verdict"]
$buf = "";
$event = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($runInput),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Accept: text/event-stream",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: " . $key,
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) { $event = substr($line, 7); }
elseif (str_starts_with($line, "data: ") && $event === "delta") {
$buf .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
echo $buf;
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(runInput), Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) is not null) {
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
buf.Append(JsonDocument.Parse(line[6..]).RootElement.GetProperty("text").GetString());
}
Console.WriteLine(buf.ToString());
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")
'
import re
def parse_reply(text, prescan=None):
s = text.strip()
s = re.sub(r"^```[A-Za-z0-9_-]*\s*", "", s) # an accidental opening fence
s = re.sub(r"\s*```$", "", s) # and its closing partner
s = s[s.index("{"):s.rindex("}") + 1] # outermost object, whatever else leaked
reply = json.loads(s)
sent = [f["id"] for f in (prescan or {}).get("flags", [])]
got = [c.get("id") for c in reply.get("coverage_check", [])]
uncovered = [i for i in sent if got.count(i) != 1] # missing OR duplicated
unsent = [i for i in got if i not in sent]
if uncovered or unsent:
raise ValueError("coverage_check broken: uncovered=%s unsent=%s" % (uncovered, unsent))
return reply
reply = parse_reply(j["output"]["output"], run_input.get("prescan_facts"))
print(reply["lane"], reply["verdict"], reply["objects_seen"]["tables"], "tables seen")
for f in reply["findings"]:
print(f["id"], f["severity"], f["area"], f["object"], "-", f["what"])
function parseReply(text, prescan) {
let s = String(text).trim();
s = s.replace(/^```[A-Za-z0-9_-]*\s*/, "").replace(/\s*```$/, "");
s = s.slice(s.indexOf("{"), s.lastIndexOf("}") + 1);
const reply = JSON.parse(s);
const sent = (prescan?.flags ?? []).map((f) => f.id);
const got = (reply.coverage_check ?? []).map((c) => c.id);
const uncovered = sent.filter((id) => got.filter((g) => g === id).length !== 1);
const unsent = got.filter((id) => !sent.includes(id));
if (uncovered.length || unsent.length) {
throw new Error("coverage_check broken: uncovered " + uncovered.join(", ") +
"; unsent " + unsent.join(", "));
}
return reply;
}
const reply = parseReply(job.output.output, runInput.prescan_facts);
console.log(reply.lane, reply.verdict, reply.objects_seen.tables, "tables seen");
for (const f of reply.findings) console.log(f.id, f.severity, f.area, f.object, "-", f.what);
type coverage struct {
ID string `json:"id"`
Handling string `json:"handling"`
Note string `json:"note"`
}
type reply struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
CoverageCheck []coverage `json:"coverage_check"`
}
func parseReply(text string, sentIDs []string) (*reply, error) {
s := strings.TrimSpace(text)
if strings.HasPrefix(s, "```") {
if i := strings.Index(s, "\n"); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
}
start, end := strings.Index(s, "{"), strings.LastIndex(s, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("no JSON object in the reply")
}
var r reply
if err := json.Unmarshal([]byte(s[start:end+1]), &r); err != nil {
return nil, err
}
seen := map[string]int{}
for _, c := range r.CoverageCheck {
seen[c.ID]++
}
for _, id := range sentIDs {
if seen[id] != 1 {
return nil, fmt.Errorf("flag %s appears %d times in coverage_check", id, seen[id])
}
}
return &r, nil
}
static String outermostObject(String text) {
String s = text.strip();
if (s.startsWith("```")) { // an accidental code fence
int nl = s.indexOf('\n');
if (nl >= 0) s = s.substring(nl + 1);
int close = s.lastIndexOf("```");
if (close >= 0) s = s.substring(0, close);
}
int a = s.indexOf('{'), b = s.lastIndexOf('}');
if (a < 0 || b <= a) throw new IllegalStateException("no JSON object in the reply");
return s.substring(a, b + 1);
}
// Then, with your JSON reader: collect every coverage_check[].id, and assert
// that each prescan_facts.flags[].id you sent occurs there exactly once and
// that no id you did not send occurs at all. Anything else is a contract
// violation - resend the same body with a retry_note naming the missing ids.
def parse_reply(text, prescan = nil)
s = text.strip
s = s.sub(/\A```[A-Za-z0-9_-]*\s*/, "").sub(/\s*```\z/, "")
s = s[s.index("{")..s.rindex("}")]
reply = JSON.parse(s)
sent = (prescan && prescan["flags"] || []).map { |f| f["id"] }
got = (reply["coverage_check"] || []).map { |c| c["id"] }
uncovered = sent.reject { |id| got.count(id) == 1 }
unsent = got.reject { |id| sent.include?(id) }
raise "coverage_check broken: #{uncovered} / #{unsent}" unless uncovered.empty? && unsent.empty?
reply
end
reply = parse_reply(job["output"]["output"], run_input["prescan_facts"])
puts "#{reply["lane"]} #{reply["verdict"]} - #{reply["headline"]}"
function parse_reply(string $text, ?array $prescan = null): array {
$s = trim($text);
$s = preg_replace('/\A```[A-Za-z0-9_-]*\s*/', "", $s);
$s = preg_replace('/\s*```\z/', "", $s);
$a = strpos($s, "{");
$b = strrpos($s, "}");
if ($a === false || $b === false || $b <= $a) {
throw new RuntimeException("no JSON object in the reply");
}
$reply = json_decode(substr($s, $a, $b - $a + 1), true);
$sent = array_column($prescan["flags"] ?? [], "id");
$got = array_column($reply["coverage_check"] ?? [], "id");
$counts = array_count_values($got);
$uncovered = array_values(array_filter($sent, fn($id) => ($counts[$id] ?? 0) !== 1));
$unsent = array_values(array_diff($got, $sent));
if ($uncovered || $unsent) {
throw new RuntimeException("coverage_check broken: "
. json_encode(["uncovered" => $uncovered, "unsent" => $unsent]));
}
return $reply;
}
static JsonElement ParseReply(string text, IEnumerable<string> sentIds) {
var s = text.Trim();
if (s.StartsWith("```")) { // an accidental code fence
var nl = s.IndexOf('\n');
if (nl >= 0) s = s[(nl + 1)..];
var close = s.LastIndexOf("```", StringComparison.Ordinal);
if (close >= 0) s = s[..close];
}
int a = s.IndexOf('{'), b = s.LastIndexOf('}');
if (a < 0 || b <= a) throw new InvalidOperationException("no JSON object in the reply");
var reply = JsonDocument.Parse(s[a..(b + 1)]).RootElement;
var got = reply.GetProperty("coverage_check").EnumerateArray()
.Select(c => c.GetProperty("id").GetString()).ToList();
var uncovered = sentIds.Where(id => got.Count(g => g == id) != 1).ToList();
var unsent = got.Where(id => !sentIds.Contains(id)).ToList();
if (uncovered.Count > 0 || unsent.Count > 0)
throw new InvalidOperationException(
$"coverage_check broken: uncovered {string.Join(", ", uncovered)}; " +
$"unsent {string.Join(", ", unsent)}");
return reply;
}
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
| field | type | notes |
|---|---|---|
lane | string | equals the task you asked for. A reply that disagrees means the router fell back; read verdict_reason to find out why. |
title | string | 90 characters or fewer, naming the model or report and what was done to it. |
dataset | string | the semantic model or report name taken from the input, or the literal "unnamed". |
verdict | string | one of this lane's three values. See the lane tables below. |
verdict_reason | string | one or two sentences on why that verdict and not the neighbouring one. |
headline | string | 220 characters or fewer — the one sentence a reader would quote. |
input_kind | string | tmdl bim report-json perf-analyzer mixed prose unknown. The reviewer's own judgement, which may disagree with prescan_facts.kind. |
objects_seen | object | {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. |
summary | string | three 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.
| field | type | notes |
|---|---|---|
schema | object | {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.
| field | type | notes |
|---|---|---|
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[] field | notes |
|---|---|
name, table | the measure name exactly as it appears, and its home table or "unknown". |
original | verbatim 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. |
rewritten | the reviewer's version, or the empty string when it is leaving the measure alone. |
pattern | the named anti-pattern, for example "FILTER over a whole table inside CALCULATE". |
why | why the rewrite is faster or clearer, in engine terms. |
expected_gain | large / moderate / small / readability-only / none. |
behaviour_change | none / 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_note | what could return a different number, or the empty string when nothing can. |
verify | the 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.
| field | type | notes |
|---|---|---|
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. |
budget | object | {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.
| field | type | notes |
|---|---|---|
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.
| field | type | notes |
|---|---|---|
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. |
accessibility | object | {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
/guest,/meand/estimateare free./runand/run-streamare metered and need a personal token.hold_creditsis reserved, priced against the full output cap, and differs per lane. The settled figure incharged_creditsis usually far lower. Never quote the hold as a price.- If the balance sits between
min_creditsandhold_credits, the run still executes with a reduced output cap and the terminal job carriestruncated: true. Render what arrived and offer a top-up — do not present a clipped review as complete, especially in thetmdllane where the missing part is authorable text. - Failed runs are not billed. A reformat retry is a second billed run, which is why the web app tells the user before it spends one.
- The publisher's markup comes back as
markup_bpson every estimate.
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.