Install
$ agentstack add skill-kaelio-ktx-metabase-ingest ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Metabase to ktx Semantic Layer
Each WorkUnit represents one Metabase collection's cards for one Metabase database (mapped to exactly one ktx connection). Every cards/.json file carries the resolved SQL, result_metadata, card type, collection path, and referenced-card ids. The WU's sync-config.json tells you which sync mode is active and which selections apply. databases/.json tells you the target ktx connection.
Context format
Each card JSON looks like:
{
"metabaseId": 7,
"name": "Daily orders",
"description": "Orders by day",
"type": "model",
"databaseId": 42,
"collectionId": 5,
"resolvedSql": "SELECT ...",
"templateTags": [{"name": "ref", "type": "card", "cardReference": 10}],
"resultMetadata": [
{"name": "day", "base_type": "type/DateTime", "semantic_type": "type/CreationTimestamp"},
{"name": "order_count", "base_type": "type/Integer"}
],
"collectionPath": ["Data", "Orders Team"],
"referencedCardIds": [10]
}
Use resultMetadata to:
- Map
base_typeto KSL column type:type/Integer,type/Float,type/Decimal,type/BigInteger→number;type/Text,type/TextLike→string;type/DateTime,type/Date,type/DateTimeWithTZ→time;type/Boolean→boolean. - Identify grain candidates: columns with
semantic_type: type/PK. - Identify join candidates: columns with
semantic_type: type/FKplusfk_target_field_id. - Identify time columns:
semantic_type: type/CreationTimestamportype/UpdatedTimestamp→ setrole: time. - Use
display_namefor measure descriptions when available.
Additional card metadata
parameters: list of card-level parameters with widget types and defaults. When SQL resolution fell back to unresolved SQL, use this to drive Step A of the SQL-translation workflow (drop optional clauses): knowing each{{ var }}istype: "date/range"vstype: "category"tells you what kind of clause it is.resultMetadata[i].field_ref: Metabase's canonical reference to the source warehouse field. Shape["field", , ]. When this is set, the column maps directly to a warehouse field, which is useful for declaring joins from FK metadata without re-parsing SQL.lastRunAt: ISO timestamp of the card's last execution. If null or very old, the card may be dead; prefer skipping over creating a source.dashboardCount: number of dashboards referencing the card. Cards withdashboardCount: 0and a stalelastRunAtare strong skip signals.
Before writing a wiki page derived from a Metabase question SQL, verify each schema.table.column mentioned with entity_details.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: ""})- see what wikis, SL sources, and raw
tables already exist. Prefer updating existing pages over creating new ones.
Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:
entity_details({connectionId, targets: [{display: ""}]})-
confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
- For literal values from the source, such as status codes or plan tiers,
check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT FROM LIMIT 50"}).
- If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM LIMIT 0"}).
If it errors, the identifier is fictional.
- Wrap the identifier in
[unverified - from ]in the wiki body,
citing the exact raw path that mentioned it.
- When recording
emit_unmapped_fallbackwithno_physical_table, include
the failing probe error in clarification.
- Never copy
.placeholder strings from these instructions
into output.
Decision tree
For each card:
- Analyze
resolvedSql+resultMetadata: identify base tables, aggregations, joins, filters, column types. - REQUIRED before any write: call
sl_discoverfor every candidate target source name. The response tells you whether the name is manifest-backed (Type: tableorType: sql). For manifest-backed names you MUST use the overlay shape (name:plus overlay fields such asmeasures:,segments:,descriptions:,joins:,disable_joins:,column_overrides:, and computed-onlycolumns:entries withexpr+type; nosql:,table:,grain:, or base-tablecolumns:); the tool will reject a standalone write and you'll have wasted the call. Ifsl_discoverreturns nothing for the name, you can write a standalone source. Also callsl_read_sourceon existing sources you intend to extend so you don't duplicate measures. - Include
rawPaths: ["cards/.json"]on everysl_write_source,sl_edit_source, andwiki_writecall. If one artifact generalizes multiple near-duplicate cards, include each contributing card path and no unrelated cards. - Decide:
- Simple aggregation on a table that already has a source →
sl_edit_sourceto add a measure. - Join between tables that should be linked in the SL graph →
sl_edit_sourceto add a join. - Complex derived SQL (CTEs, multi-layer aggregation, scoring models) →
sl_write_sourcewithsource_type: sql. When the SQL projects/filters from a single manifest-backed base table, setinherits_columns_from:so columns inherit type and description from the manifest - seesl_captureskill for the slim form. Usesl_discoverto discover the manifest key from the table reference in the SQL (it acceptsMARTS.CONSIGNMENTS,ANALYTICS.MARTS.CONSIGNMENTS, orCONSIGNMENTS). - New base table not yet in the semantic layer →
sl_write_sourcewithsource_type: table. - Trivial query (
SELECT *, simpleCOUNT(*)with no business logic) → do nothing; the runner will record this card asaction_type='skipped'. - Duplicate of an existing measure → same as trivial; do nothing for this card.
Manifest-only names need an overlay first. If sl_discover shows a source name with Type: table but sl_read_source returns "Source not found", the source lives only in the schema manifest (no standalone overlay yet). sl_edit_source cannot edit manifest-only names, and a full standalone sl_write_source for that name would shadow manifest columns and joins. Bootstrap an overlay with sl_write_source using the overlay shape:
name:
measures:
- name:
expr: ""
Overlay shape: name: plus any of measures:, segments:, descriptions:, joins:, disable_joins:, exclude_columns:, column_overrides:, or computed-only columns: entries with expr + type. Never include sql:, table:, grain:, or base-table columns: on a manifest-backed name — those would shadow the manifest's schema and drop its joins. Use column_overrides: for inherited column descriptions. Overlay joins: are merged additively with the manifest's joins (deduped by to + on); use disable_joins: [""] to suppress a specific manifest join. After the overlay exists, use sl_edit_source for further tweaks. See sl_capture skill for the canonical overlay rule.
Join discovery: When your card's SQL references warehouse tables (e.g. in FROM or JOIN clauses), call sl_discover({ query: '' }) before writing. The matching manifest entry's name is the value you use in joins: [- to: ] only when the card output exposes a local key that matches the target source grain (for example account_id = mart_account_segments.account_id). Do not declare a ktx join just because the card SQL joins that table internally. If the output only exposes display fields such as account_name, keep the SQL source self-contained or project the key before adding the join. Use many_to_one for FK-to-dimension joins, one_to_many for the reverse.
Hard rule on join columns (prevents broken joins): For every join you declare, the local column on the left of on: MUST be (a) present in your source's projected output and (b) a key/ID column, never a display value. If the natural FK isn't in your SELECT, add it to SELECT before declaring the join. Joining account_name = mart_account_segments.account_id is always wrong - names are not identifiers and the equality produces zero matches. The validator rejects this with a "display value to identifier" error; the tool will refuse to save it. Add account_id to your SELECT and join on account_id = mart_account_segments.account_id, or omit the join entirely.
priorProvenance
If the WU prompt includes a priorProvenance section for a card, it tells you what happened on prior ingest syncs. Treat it as advisory:
action_type: source_createdon source X → prefer editing X withsl_edit_sourcerather than writing a new source.action_type: measure_addedon source X → you already contributed to X; add only measures that aren't present.action_type: subsumedormerged→ this card was folded into another source last time; unless its SQL has changed structurally, keep it subsumed (no new write).action_type: skipped→ last time we decided not to ingest this card; re-read the SQL and confirm the decision still holds. If the card now has non-trivial business logic, ingest it.
Deduplication
Before writing, scan all cards in this WU for near-duplicate groups - cards whose resolvedSql shares the same CTEs, base tables, joins, and aggregation structure but differs only in:
- Trailing filters (e.g.
date_trunc(week, date)vsdate_trunc(month, date)). - Minor
WHEREclause variations. - Column aliases or output column subsets.
- Aggregation granularity (daily vs weekly vs monthly).
When you find a group of near-duplicates:
- Create ONE generalized source from the most comprehensive card in the group.
- Strip card-specific trailing filters from the SQL so the source covers all variants (e.g. keep daily grain instead of filtering to week/month).
- If each card had a distinct measure or filter, add them as separate measures on the single source.
- For all cards except the canonical one, do nothing - they'll be recorded as
action_type='skipped'automatically by the runner.
Do NOT merge cards with fundamentally different business logic, even if they share CTEs.
Pre-aggregation decomposition
When a card's resolvedSql contains GROUP BY with aggregation functions (SUM, COUNT, AVG, …):
- Detect: simple aggregation on base tables/joins -
SELECTwithGROUP BY, no complex CTEs or window functions. - Decompose: strip the
GROUP BYand aggregation functions. KeepFROM,JOIN, andWHEREintact. - Expose row-level columns: include the grouped-by columns AND the raw columns being aggregated (e.g.
money_outinstead ofSUM(money_out) AS total_money_out). - Define aggregations as measures: convert each aggregation into a KSL measure (e.g.
sum(money_out)). - Add joins: with FK columns now exposed, declare joins to dimension sources.
Exception: keep the pre-aggregated SQL when the query involves multi-CTE pipelines, window functions, or recursive logic where decomposition would lose business logic.
SQL translation from raw native to KSL
Every card carries a resolvedSql field. Check the staged card's resolutionStatus first:
resolutionStatus: "resolved"-{{#N}}references are inlined and[[ ... ]]optional clauses have been dropped locally. If the resolved SQL contains no other parameters the SQL is executable as-is. If the card had required (non-bracketed){{ var }}placeholders, the SQL is prefixed with a placeholder-warning comment block listing every dummy substitution Metabase made - see "Step A" below.resolutionStatus: "fallback"- Metabase failed to resolve. The SQL still contains{{#N}},{{#N-name}} alias,{{ var }}, and[[ ... ]]syntax. Do the translation steps below before writing a source.
Step A - Handle dummy-substituted placeholders (resolved cards only)
When a card has a required {{ var }} outside any [[ ]] block, the resolver substitutes a dummy value purely so Metabase's parser will accept the query. The resulting SQL is prefixed with a comment like:
-- PLACEHOLDER_WARNING: this SQL was extracted from a Metabase card with
-- unbound template parameters. The placeholders below were substituted with DUMMY
-- values to satisfy Metabase's parser - they DO NOT represent intended filters.
-- Drop the corresponding clauses (or expose them as runtime SL filters) before
-- persisting this SQL as a semantic-layer source.
-- {{ auction_end }} (type=dimension, widget=date/all-options) → '2020-01-01~2020-12-31'
-- {{ status }} (type=text) → 'placeholder'
SELECT ...
WHERE start_date >= '2020-01-01' AND start_date ` and `rawPaths: ["cards/.json"]` so the gap is documented, then call `emit_unmapped_fallback` with the staged card path as `rawPath`, `reason: "missing_target_table"`, `tableRef: ""`, and `fallback: "wiki_only"`. Do not use this fallback if `sl_discover` resolved the table/source.
Joins on manifest-backed names compose: the manifest's joins are inherited automatically, and any overlay `joins:` are merged on top (deduped by `to` + `on`). Use `disable_joins: [""]` in the overlay to suppress a specific manifest join. If `sl_discover` shows a manifest-backed source with `Joins: 0` and the warehouse FK metadata is genuinely absent, declaring application-level joins via the overlay is fair game - bootstrap with `sl_write_source` (overlay shape above), then refine via `sl_edit_source`.
## Cross-card references (`{{#N}}`)
Resolved cards (`resolutionStatus: "resolved"`) have these inlined for you. Unresolved cards (`resolutionStatus: "fallback"`) need manual handling - see "SQL translation from raw native to KSL" above.
## Provenance markers
Every SL source and wiki page you write carries HTML-comment provenance tags pointing to the `cards/.json` files they derive from:
```yaml
#
name: orders
...
If a source is derived from multiple cards (e.g. a generalized source for a near-duplicate group), emit one tag per contributing card.
Quality standards
Source definitions must follow ktx-sl YAML conventions:
source_type:"table"(physical table/view) or"sql"(arbitrary SQL / derived view).table: required whensource_type: "table"(e.g."public.orders").sql: required whensource_type: "sql".grain: what one row represents (e.g.[id],[customer_id, product_id]).columns: all columns with correct types (string,number,time,boolean).- Time columns: mark with
role: time. joins: use correctrelationshiptypes (many_to_onefor FK→PK,one_to_manyfor reverse).joins.on:local_column = TARGET_SOURCE.target_column- the right side MUST include the target source name.measures.expr: aggregation expression (e.g."sum(amount)"); optionalfilterfor business rules; requireddescription.
Measure naming: descriptive snake_case (e.g. total_revenue, avg_order_value).
Rules
- Prefer adding measures to existing sources over creating new ones.
- Before editing, always
sl_read_sourcethe source to check for existing measures. - Don't duplicate measures (same aggregation on the same column).
- If two measures differ only by a filter (e.g.
revenuevspaid_revenue), they are distinct. - Use the card's
name+descriptionto write meaningful measure descriptions. - When multiple cards in a WU are near-duplicates, create ONE generalized source; the runner will skip the rest automatically.
- Process every card in the WU - don't stop early.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Kaelio
- Source: Kaelio/ktx
- License: Apache-2.0
- Homepage: https://docs.kaelio.com/ktx
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.