About powerbi-to-sl
powerbi-to-sl migrates existing Power BI artifacts into Keboola Semantic Layer JSON payloads. It suits teams moving established models, translating structure mechanically while preserving DAX for review before an optional, confirmed push.
Key features
- 01Detects TMDL and per-table JSON input
- 02Maps tables, measures, and relationships
- 03Preserves DAX expressions and reports warnings
- 04Validates output against JSON schemas
- 05Supports confirmed push or sl-toolkit handoff
Use cases
- 01Migrating a Power BI model to Keboola SL
- 02Reviewing DAX measures before SQL rewriting
- 03Preparing and validating semantic-layer payloads
MCP support
The skill can use Microsoft's Power BI Modeling MCP in read-only mode to produce TMDL when needed. It can also hand off publishing to sl-toolkit.
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ keboola/ ai-kit --skill powerbi-to-slAfter installing, type/powerbi-to-slin Claude Code to confirm it loaded.
Details
From the project READMEpowerbi-to-sl — Power BI → Keboola Semantic Layer Migration
Read existing PowerBI artifacts → map to Keboola SL payloads → write JSON
files → user confirms → push via sl-toolkit or direct REST. Companion to
sl-toolkit (greenfield); this is the brownfield path.
Step 0 — Identify input
Ask the user where the PowerBI artifacts are, then auto-detect format:
INPUT_DIR="$(read -p 'Path to PowerBI artifacts: ' x; echo "$x")"
python3 -c "
import os, sys
p = sys.argv[1]
tmdl = [f for f in os.listdir(p) if f.endswith('.tmdl')] if os.path.isdir(p) else []
jsons = [f for f in os.listdir(p) if f.endswith('_semantic_layer.json')] if os.path.isdir(p) else []
if tmdl: print('tmdl')
elif jsons: print('per-table-json')
else: print('unknown')
" "$INPUT_DIR"
If unknown, ask the user to clarify, or have them run Microsoft's Power BI
Modeling MCP with --readonly against their dataset to produce TMDL first.
Step 1 — Business questions (before mapping)
"Three quick questions:
- What is the Keboola bucket prefix for these tables? (e.g.,
in.c-pbi-migration— used to buildtableIdfor each semantic-dataset)- Semantic model name + display name? (e.g.,
acme-pbi-migration/ "Acme Power BI Migration")- Snowflake or BigQuery dialect? (default: Snowflake)"
Persist answers as BUCKET_PREFIX, MODEL_NAME, MODEL_DISPLAY, DIALECT.
Step 2 — Run the migrator
python3 scripts/migrate.py \
--input "$INPUT_DIR" \
--input-format "$FORMAT" \
--bucket-prefix "$BUCKET_PREFIX" \
--model-name "$MODEL_NAME" \
--model-display "$MODEL_DISPLAY" \
--dialect "$DIALECT" \
--output ./out/
Output layout:
out/
semantic-model.json
semantic-dataset/
<bucket-prefix>.<table>.json # one per PowerBI table
semantic-metric/
<measure-slug>.json # one per PowerBI measure
semantic-relationship/
<from>__<to>.json # one per PowerBI relationship
WARNINGS.md # DAX-not-translated, unmapped types, etc.
Step 3 — Review WARNINGS.md with the user
The migrator preserves DAX measure expressions verbatim in
semantic-metric.sql. Show the user WARNINGS.md and ask whether to:
- Punt — leave DAX in place; downstream Kai (or a human) rewrites as SQL on-demand.
- Auto-translate trivial cases —
SUM('Table'[Col])→SUM("Table"."Col"),COUNT,DISTINCTCOUNT,AVERAGE. Anything withCALCULATE, filter context,RELATED, time-intelligence, or variables stays flagged. - Human pass — open the WARNINGS list and rewrite line by line.
Default: punt + show the list. Trivial-case translation is opt-in.
Step 4 — Validate against Keboola SL schemas (jsonschema)
python3 -c "
import json, jsonschema, glob, sys, os
schemas_dir = os.environ['SCHEMAS_DIR']
schemas = {
name: json.load(open(f'{schemas_dir}/semantic-{name}_schema_1.0.0.json'))
for name in ['model','dataset','metric','relationship']
}
errs = 0
for kind, schema in schemas.items():
pattern = './out/semantic-model.json' if kind == 'model' else f'./out/semantic-{kind}/*.json'
for f in glob.glob(pattern):
try:
jsonschema.validate(json.load(open(f)), schema)
except jsonschema.ValidationError as e:
errs += 1
print(f'❌ {f}: {e.message}')
print(f'{errs} validation errors' if errs else '✅ all valid')
"
The same validation runs as a pytest assertion in tests/test_smoke.py. Set
SCHEMAS_DIR to the directory containing the Keboola metastore semantic-*
JSON schemas.
Step 5 — Push (optional, gated on user confirmation)
Confirm with the user before any write. Two paths:
Path A — via sl-toolkit (preferred if that plugin is installed; it
owns the canonical generate–validate–push pipeline):
"Hand off to
sl-toolkit? It owns the canonical push pipeline for any SL model. Or push directly to the metastore REST API?"
If sl-toolkit chosen, invoke its push step with --source ./out/.
Path B — direct REST (when sl-toolkit is not available):
import json, os, urllib.request, glob
from concurrent.futures import ThreadPoolExecutor
TOKEN = os.environ['KBC_STORAGE_TOKEN']
METASTORE = os.environ['KBC_METASTORE_URL'] # e.g. https://metastore.us-east4.gcp.keboola.com
def post(kind, payload):
req = urllib.request.Request(
f'{METASTORE}/api/v1/repository/semantic-{kind}',
data=json.dumps(payload).encode(),
headers={'X-StorageAPI-Token': TOKEN, 'Content-Type': 'application/json'},
method='POST')
return urllib.request.urlopen(req, timeout=30).read()
# Model first — get the UUID, propagate to children
model_uuid = json.loads(post('model', json.load(open('./out/semantic-model.json'))))['data']['id']
for kind in ['dataset','metric','relationship']:
payloads = []
for f in glob.glob(f'./out/semantic-{kind}/*.json'):
p = json.load(open(f))
p['modelUUID'] = model_uuid
payloads.append(p)
with ThreadPoolExecutor(max_workers=10) as ex:
list(ex.map(lambda p: post(kind, p), payloads))
Mapping reference
| Power BI | Keboola SL |
|---|---|
tables[].name | semantic-dataset.name + tableId |
tables[].description | semantic-dataset.description |
tables[].columns[].name | semantic-dataset.fields[].name |
tables[].columns[].dataType | semantic-dataset.fields[].type * |
tables[].columns[].description | semantic-dataset.fields[].description |
| PK column hints | semantic-dataset.primaryKey |
tables[].measures[].expression (DAX) | semantic-metric.sql (verbatim) |
tables[].measures[].description | semantic-metric.description |
relationships[] | semantic-relationship |
Type mapping: string→string, int64/integer→integer,
double/decimal/currency→decimal, dateTime/date→datetime/date,
boolean→boolean. Unknown types fall back to string and get logged.
Known gotchas
- Per-table JSON loses Power Query M steps. The older
get_semantic_modeloutput does not capture M expressions. If full fidelity is required, re-extract with Microsoft's Power BI Modeling MCP--readonlyflag to produce TMDL, which captures DAX and M natively. - DAX ≠ SQL. Verbatim preservation is the default; downstream Kai or a
human translates.
CALCULATE,RELATED, time-intelligence, and variables do not survive trivial translation. - PowerBI table names contain spaces and non-ASCII characters. Slugify
for filenames; keep originals in
name/displayName. UTF-8 paths are fine on macOS/Linux but watch for filesystem-quirky environments. - PK detection is best-effort. Power BI doesn't always mark PKs
explicitly; the migrator uses (a) explicit
isKeymarkers in TMDL, (b) relationshiptocolumns as a heuristic, (c) falls back to empty primaryKey + a WARNINGS entry asking the user to pick. - Relationship cardinality.
manyToOneandoneToOne→inner;oneToMany→leftfrom the "many" side.manyToManyproduces a WARNINGS entry — the SL model expects star-schema friendliness.
Why two skills exist (powerbi-to-sl vs. sl-toolkit)
| Aspect | sl-toolkit | powerbi-to-sl (this) |
|---|---|---|
| Direction | Greenfield (Keboola tables → SL) | Brownfield (Power BI → SL) |
| Input | Keboola storage + SQL transformations | TMDL folder or per-table JSON |
| Generation | LLM-driven from schema + KPIs | Mechanical structural translation |
| Validation | Auto-fix + LLM critique | jsonschema validate + WARNINGS |
| Push | Owns push pipeline | Hands off to sl-toolkit push |
When both are installed, this skill produces SL payloads and delegates
push to sl-toolkit. Reuse, not overlap.