Kaelio/ktx
GitHub用于处理需从ktx数据库获取数据的分析任务,如数据探索、指标解释、记录查找及周期对比。通过发现、检查、解析业务值、规划、查询、验证及记忆吸收的七步工作流,确保数据查询的准确性与完整性。
安装全部 Skills
npx skills add Kaelio/ktx --all -g -y
更多选项
预览集合内 Skills
npx skills add Kaelio/ktx --list
集合内 Skills (16)
packages/cli/src/skills/analytics/SKILL.md
npx skills add Kaelio/ktx --skill ktx-analytics -g -y
SKILL.md
Frontmatter
{
"name": "ktx-analytics",
"description": "Use when answering a question that needs data from a ktx-connected database - investigating, analyzing, \"how many\", \"show me\", \"what's the breakdown of\", finding records by value, exploring tables, comparing periods, explaining metrics, or any data-analysis request. Triggers even when the user does not say \"analytics\"; if the answer requires querying a configured ktx connection, this skill applies."
}
ktx Analytics Workflow
You have access to ktx MCP tools for data discovery, semantic-layer analysis, raw read-only SQL, wiki context, and memory ingest. Follow this workflow.
<sql_craft> Heuristics for writing correct (not merely runnable) SQL. Each is a default plus the reason it holds on any database; apply judgment to the question and the data.
Schema discovery before writing SQL
- Sample before you compose. Inspect representative rows of every table you will touch (
entity_detailsplus a smallsql_executionsample) to confirm date/time encoding (YYYYMMDDinteger vs ISO text vs epoch), null prevalence in join/filter keys, and the real set of categorical/enum values. Assumptions about encoding and nullability are the most common source of silently-wrong filters. - Cast to the real type before comparing. Compare a column against a literal of its actual type in
WHERE/JOIN. A string column compared to a numeric literal (or the reverse) can silently match nothing instead of raising an error. - Parse text-encoded numerics before doing math on them. When a column the question treats as a number is stored as text, sample its distinct values (the Sample before you compose habit) to learn the encodings actually present — unit suffixes (
K/M/B), currency symbols, thousands separators, percent signs, and non-numeric sentinels (-,N/A, empty) — and never infer the format from the column name. Why: aggregated or compared as-is the text sorts lexically ('100' < '9') and a naive cast collapses formatted values to0/NULL, so the query runs but the number is silently wrong instead of erroring. - Strip, scale, and cast in one early CTE. Strip currency/separator/percent characters, multiply by the suffix scale (
K=10^3,M=10^6,B=10^9), map sentinels to0orNULL(by the Default by additivity rule below), then cast to a numeric type — all in a single early CTE so every layer above sees clean numbers. This is the meaning-is-numeric complement to Cast to the real type before comparing. Why: one clean conversion at the base keeps the lexical-sort-and-cast-to-0 failure out of every downstream layer. - Confirm the parse covered every value. After parsing, count the non-sentinel rows that failed to parse — a failed parse should surface as
NULL, visible only with a failure-detecting cast fromsql_dialect_notes(a plainCASTerrors on some engines and on sqlite silently returns0/partial, so anIS NULLcheck is meaningless there). Why: an encoding the sample missed would otherwise vanish into0/NULL instead of being caught. - Parse code/dependency text by its real grammar, not one broad regex. When a question extracts imported/required/loaded packages or modules from stored source text or dependency manifests, parse by the language or format, not a single pattern: Java
import/import static— drop the terminal class/member, keep the package path, and allow valid identifier segments with underscores and mixed case (e.g. com.planet_ink.coffee_mud); Python — handle bothimport a, b as candfrom a.b import c, stripping aliases; R — handlelibrary(...)andrequire(...); notebooks (.ipynb) — parse the JSON and read each cell'ssourcelines before applying the language rules (never regex the raw notebook file, whose prose contains the words "import"/"from"); JSON/manifest files —PARSE_JSONand flatten the dependency object's keys (e.g.require). Strip comments/prose lines first and split multi-import lines so each declared dependency is counted once. Why: a single lowercase-segment regex silently drops real identifiers and matches prose, so the ranking is wrong though the query runs. - Decide the counting population explicitly when a table is deduplicated. If the source table is de-duplicated and carries a documented copy/occurrence count (e.g. a
copiescolumn = "repositories sharing this exact content"), the count grain is a real modeling choice: weight by that column only when the question's population is clearly the represented files/repositories; otherwise count the distinct stored rows. State which population the question names and match it — do not default to one silently. Why: on a deduplicated tableCOUNT(*)andSUM(copies)give different rankings, so the right metric depends on the population the question asks about, not on which is larger.
-- "Total trade volume" where value_text holds '1.2K', '3M', '$1,200', '-'.
-- WRONG: a naive cast collapses the formatted values ('1.2K'->1.2, '$1,200'->0,
-- '-'->0) instead of erroring, so the SUM comes back silently far too low.
SELECT SUM(CAST(value_text AS REAL)) AS total_volume FROM metrics;
-- RIGHT: strip symbols/suffixes, scale by the K/M/B suffix, map sentinels to 0, and
-- cast once in an early CTE; the SUM then runs over clean numbers.
WITH parsed AS (
SELECT CASE WHEN value_text IN ('-', 'N/A', '') THEN 0
ELSE CAST(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(value_text,
'$', ''), ',', ''), 'K', ''), 'M', ''), 'B', '') AS DECIMAL(18, 4))
* CASE WHEN value_text LIKE '%K' THEN 1000
WHEN value_text LIKE '%M' THEN 1000000
WHEN value_text LIKE '%B' THEN 1000000000 ELSE 1 END
END AS volume
FROM metrics
)
SELECT SUM(volume) AS total_volume FROM parsed;
- Canonicalize observed URL-path variants before page-level analysis. When a question groups, filters, or sequences web pages by a
path/urlcolumn, sample its distinct values first. If the data itself shows route-label variants —/routeand/route/for the same page context — define a canonical page-path expression in an early CTE and use it everywhere above that CTE: preserve/as root, strip trailing slashes only from non-root paths, and map an observed empty path to/only when the column is a URL path and the sampled rows show blank root-page events. Do not merge different route names (/input≠/regist/input), strip query strings/fragments/host/scheme, lowercase paths, or canonicalize at all when the question asks for the raw stored URL/path or for slash-vs-no-slash differences. Why: raw request logs routinely store the same user-visible page both with and without a trailing slash, so grouping or sequencing the raw labels silently splits one page into several — but inventing aliases the data doesn't show would just as silently merge distinct pages.
Composition
- Build incrementally. Assemble complex queries one CTE at a time, checking each layer's output on a small sample before stacking the next; a wrong intermediate layer is far cheaper to catch early than to debug in the final number.
- Avoid fan-out joins — the danger is cumulative. Any one-to-many hop on the path between a measure's owning table and the aggregate inflates that measure, even when the offending join sits several hops below the
SUM/COUNTand is easy to miss. The fix is the single-hop one applied per measure-owning table along the whole chain: pre-aggregate each coarse-grained measure to its own grain in a CTE, then join the already-aggregated result. - Verify the grain holds across each join. As you compose, confirm a join you intend to be one-to-one / many-to-one did not change the grain you aggregate at — e.g. the row count (or the count of the aggregate's key) is unchanged across it. When a join is genuinely one-to-many, reach for the default fix (pre-aggregate to grain); for a pure count,
COUNT(DISTINCT key)is an acceptable escape hatch. ASUM/AVGof a fanned-out measure must pre-aggregate —DISTINCTcannot de-duplicate a sum. - A join that only attaches a label must not drop rows —
LEFT JOINit, and key the aggregate on the fact column. Fan-out's mirror image is just as silent: when you join a dimension table only to fetch a display attribute (a name for an id, a category for a product), an incomplete dimension — and dimensions are routinely incomplete: trimmed catalogs, late-arriving rows, slowly-changing-dimension gaps — makes a plain innerJOINquietly discard every fact row whose key has no parent, shrinking the counts, sums, and the universe over which any share / average / median is computed (a measure halves with no error and no empty result). Two guards: (1) inner-join a dimension only when you intend it as a filter — you want exactly the rows that have a parent — never merely to read a column off it; for pure enrichment useLEFT JOIN. (2) Key the aggregation andGROUP BYon the fact column (sales.prod_id), not the dimension column (products.prod_id), so an unmatched key yields aNULLlabel on its own row rather than dropping or collapsing it. Use the same row-count check as above, but for an enrichment join confirm the fact row count is unchanged (not merely un-inflated); if a dimension you only wanted a name from removed rows, that is the bug. - Source each filter, date, and measure from the table that OWNS it at the question's grain. When two joined fact tables carry similarly-named columns at different grains — a parent (one row per order: its
status, placementcreated_at,num_of_item) and its child (one row per line item: linecreated_at,sale_price,cost) — read each predicate/measure from the table whose grain the question names, not from whichever is in scope after the join. "Orders that are Complete", "for each month of the orders", "the order's creation date" are order-grain, so the status filter and the month bucket come from the parent order row, even though the child also hasstatus/created_atcolumns; line price and cost come from the child. Why: the parent's and child's copies of a column diverge (an item's placement month or status can differ from its order's), so anchoring an order-grain filter or calendar on the line table silently buckets/filters the wrong rows. The mirror at metric grain: never combine a parent-grain count with child rows after the join (e.g.num_of_item * SUM(line_price)once per line) — compute each measure at its own grain (sum line prices to the order, takenum_of_itemonce per order) before combining.
-- "How many orders per region contain a returned item?" — count each order once.
-- WRONG: order_lines is joined to apply the line-level filter, which multiplies
-- orders; an order with two returned lines is counted twice, three joins below
-- the COUNT, where the inflation is easy to miss.
SELECT r.region_id, COUNT(*) AS n_orders
FROM regions r
JOIN stores s ON s.region_id = r.region_id
JOIN orders o ON o.store_id = s.store_id
JOIN order_lines l ON l.order_id = o.order_id
WHERE l.status = 'returned'
GROUP BY r.region_id;
-- RIGHT: collapse order_lines to one row per qualifying order first, then join up
-- so each order contributes exactly once.
WITH returned_orders AS (
SELECT order_id FROM order_lines WHERE status = 'returned' GROUP BY order_id
)
SELECT r.region_id, COUNT(*) AS n_orders
FROM regions r
JOIN stores s ON s.region_id = r.region_id
JOIN orders o ON o.store_id = s.store_id
JOIN returned_orders ro ON ro.order_id = o.order_id
GROUP BY r.region_id;
-- A pure count could also use COUNT(DISTINCT o.order_id); a SUM/AVG of an
-- order-level measure fanned out this way must pre-aggregate — DISTINCT can't
-- de-duplicate a sum.
Ordering & aggregation determinism
- Make the ordering deterministic. Give every ranking/ordering window a complete tie-breaker by appending unique key column(s) to
ORDER BY, soRANK/ROW_NUMBER/LAGresults are stable instead of flickering between runs. - Order inside string/array aggregation. When concatenating rows into a delimited string or building an ordered array (
GROUP_CONCAT/string_agg/array_agg), the element order is undefined unless you specify it — put an explicitORDER BYon the aggregate. Be deliberate about collation: the default text sort is binary/case-sensitive (so'BBQ'sorts before'Bacon'because uppercase code points precede lowercase), which differs from a case-insensitive sort; pick the one the question implies and apply it consistently (ORDER BY ... COLLATE NOCASEfor case-insensitive). Why: an unordered or differently-collated concatenation produces a string with the right elements in the wrong order — runnable but not matching the expected text. - Emit a list-valued answer cell as a delimited STRING, not a raw ARRAY/repeated column. When the answer needs several values in one cell (a set of names/codes/tags for an entity), build a delimited scalar with
STRING_AGG(x, ',' ORDER BY x)(orARRAY_TO_STRING(ARRAY_AGG(x ORDER BY x), ',')) — do not return a SQLARRAY/repeated column. Why: an array column serializes to an engine-specific representation (e.g.['a' 'b']or["a","b"]) that won't compare equal to a plain delimited list (a,b), so a values-correct answer still mismatches when materialized to rows. - Filter after the window, not before, for sequence / "first" / "most recent" / "since" questions: compute the window over the full partition, then keep the rows you want. A pre-filter shrinks the partition the window ranks over, so "first"/"most recent" is measured against the wrong set.
-- "Each customer's first order, restricted to orders since 2024-01-01."
-- Wrong: the filter runs before the window, so it ranks only 2024 rows and
-- misses customers whose true first order was earlier.
SELECT customer_id, order_id,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS seq
FROM orders
WHERE order_date >= '2024-01-01'; -- then keep seq = 1
-- Right: rank the full partition in a CTE, then filter in the outer query.
WITH ranked AS (
SELECT customer_id, order_id, order_date,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS seq
FROM orders
)
SELECT customer_id, order_id, order_date
FROM ranked
WHERE seq = 1 AND order_date >= '2024-01-01';
- Cumulative / running total. Use an explicit frame —
SUM(x) OVER (PARTITION BY k ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)— with a complete tie-breaker on theORDER BY(per the deterministic-ordering rule above). Why: a bareORDER BYdefaults to aRANGE-based frame bounded at the current row, which on ties in the order key folds every tied peer into one cumulative value — it runs and looks plausible, but the running total jumps at each tie boundary. - Rolling window over calendar time, plus minimum periods. "Rolling N days/months" spans a calendar range, not a fixed row count: a
ROWS BETWEEN n-1 PRECEDINGframe silently measures the wrong span when days are missing. Two sanctioned paths — (a) build a gap-free date spine first (the Series idiom fromsql_dialect_notes) so one row exists per calendar unit, then aROWS BETWEEN n-1 PRECEDING AND CURRENT ROWframe equals the intended span (fully portable); or (b) where the engine supports it, a native calendar range frame — or a date-keyed self-join — expresses the window directly: get the rolling-window idiom fromsql_dialect_notes, do not inline it. For minimum periods ("only after N periods of data"), emitNULLuntil the window is full — guard onCOUNT(*) OVER (<same frame>) = N, counting non-null observations instead when "N periods" means N data points rather than N calendar slots. Why: a row-count frame over missing dates measures the wrong span, and a partial early window is not the requested metric. - Period-over-period. Compare against the prior period with
LAG(metric) OVER (PARTITION BY k ORDER BY period); compute growth as(cur - prev) / prevat full precision, rounding only in the final projection (per the round-at-the-end rule below), and guard the divide against a zero or absent prior — e.g.… / NULLIF(prev, 0). Why: withoutLAG, or ordered against the wrong neighbor, the comparison lands on the wrong period, and an unguarded ratio errors or returns garbage when the prior period is zero or missing.
-- "Each account's running balance over time" — a cumulative sum of net per
-- account, in date order.
-- WRONG: a bare ORDER BY defaults to a RANGE-based frame, so two txns dated the
-- same day share one inflated balance (every tied peer folds into that value).
SELECT account_id, txn_date, net,
SUM(net) OVER (PARTITION BY account_id ORDER BY txn_date) AS running_balance
FROM account_txns;
-- RIGHT: an explicit ROWS frame accumulates row by row, and a complete tie-breaker
-- (txn_id) makes the order — and the running total — deterministic across ties.
SELECT account_id, txn_date, net,
SUM(net) OVER (PARTITION BY account_id ORDER BY txn_date, txn_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
FROM account_txns;
Numeric precision
- Integer division truncates on postgres/sqlite/tsql. The
/operator between two integers does integer division on postgres, sqlite, and SQL Server —5 / 2is2,wins / gamesis0— so a rate, share, orSUM(a) / COUNT(*)silently floors to an integer. Cast one operand to a fractional type before dividing:wins * 1.0 / games,CAST(wins AS REAL) / games, orSUM(a)::numeric / COUNT(*), then round at the end. mysql and bigquery already return a fractional result from/(on bigquery preferSAFE_DIVIDEto also guard a zero denominator). - Round only at the end. Compute at full precision and round in the final projection, never inside intermediate CTEs. Be explicit about truncation: an integer cast (
CAST(x AS INT)) truncates toward zero, so use explicit rounding when rounding is what you mean. - Macro vs micro average. Match the average to the wording. "Average of per-group averages" is
AVG(group_metric); an "overall" or "weighted" average isSUM(numerator) / SUM(denominator). The two diverge whenever group sizes differ.
Answer completeness / interpretation
- "Top / highest / most / lowest" returns only the winning row(s) — keep the top-ranked row from the window result — not the full ranked list, unless the question asks for a list.
- "For each X / per X / by X" returns exactly one row per X. Do not collapse to a single value unless the question says "overall" or "total across X".
- A named business measure means its amount, not a row count. When a question asks for "sales", "revenue", "spend", "value", or "volume" of money/goods without an explicit "number / count of", aggregate the monetary/quantity amount (
SUM(price)/SUM(amount)), notCOUNT(*)of rows. Why: "toy sales" reads as sales revenue; counting order rows silently answers a different question. - Answer literally — do not add unrequested transformations. Apply exactly the filters, joins, grouping, and computation the question (and any
external_knowledgedoc) states; do not add "helpful" extras the task never asked for — extra status/category predicates, area/residential weighting of an average the question states plainly, entity-name normalization that forces joins the source leaves unmatched, or a re-derived value where the question names a specific stored measure/column. When the wording bounds an aggregate ("committees whose total is between $0 and $200", "entities with 5+ orders"), filter the aggregate withHAVING, not each row withWHERE. When anexternal_knowledgedoc gives an explicit formula or function/UDF definition, implement it verbatim — same operators, constants, and ordering — rather than substituting your own "more correct" math. Why: each unrequested predicate silently drops valid rows, each unrequested weighting/normalization or re-derivation changes the value, and a row-level filter for an aggregate bound answers a different question — so a more-sophisticated-looking query is wrong against the literal ask. Prefer the simplest reading that satisfies the question. - Don't project free-text columns the question didn't ask for. A description/body/comment/notes column whose values contain commas or newlines corrupts the row-delimited output and is almost never the requested value — leave it out of the final projection unless the question explicitly asks for it.
- "Inter-event duration / gap / interval" is the time between consecutive events, not a magnitude. When the question asks the typical gap/interval/time between occurrences (releases, visits, orders), order rows by the event timestamp and take
LEAD/LAGdate differences, then aggregate — never a duration/length/runtime column. - Anchor a period bucket to the lifecycle event the wording names. When a record carries several lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named completed state by period ("delivered orders by month", "shipped items per week", "completed payments by day"), bucket the period by that named event's own timestamp (
order_delivered_customer_date,shipped_at,settled_at) — the state value is the qualifying filter, the matching timestamp is the time anchor. Use the creation/placed/purchased/submitted timestamp only when the question names that start event (purchased, placed, created, ordered, submitted) or no matching event timestamp exists. If several timestamps fit, pick the one for the event as experienced by the question's subject (customer delivery = the customer-receipt date, not the carrier-handoff or estimated date). If the named state is used only as a non-temporal filter (counts by customer/city/seller with no period bucket), it is just a filter — introduce no date anchor. Confirm each timestamp's meaning from column names, semantic-layer descriptions, and sample rows first. Why: bucketing a completed-state count by the record's creation date silently answers a different question — "records that later reached that state, grouped by when they started" — than the one asked. - "Highest / most across several achievements" aggregates per metric over the whole history. When a question asks for top values across multiple metrics or a career/lifetime total ("most runs, most wickets, longest span"), emit one row per metric with that metric summed/maxed over all the entity's records — not a single top-season or top-row snapshot.
- An aggregate scoped to a per-entity selected set is computed across that set. "The average revenue per actor in those top-3 films", "the mean order value over each customer's last 5 orders" means, per entity, the aggregate over the items it selected — one value per entity spanning its chosen items — NOT the per-item value. The per-item formula the question gives ("divide film revenue among its actors") computes each item's contribution; the average/total then spans the selected items. When the question states both a per-item computation AND an aggregate over the items, compute and project BOTH (the per-item value and the across-set aggregate, e.g.
AVG(item_value) OVER (PARTITION BY entity)). The set is chosen by the ranking measure the question names — "top-N revenue-generating films" ranks each entity's items by the item's own total revenue — and that ranking is independent of the per-item value (the share), which feeds only the aggregate, never the top-N selection. - Coverage over a selected group is a set-membership aggregate (one value for the whole group), not a per-entity metric. When a question first selects a group of entities ("the top 5 actors", "these products", "the eligible stores") and then asks what count/share/percentage of a different subject domain has any relationship to these selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: select the entity ids in a CTE, join to the subject facts,
COUNT(DISTINCT subject_id)once across the group, and return one aggregate at the subject-domain grain (with the numerator/denominator projected if the question states a ratio). Counting the subject per selected entity and reporting N rows answers a different question and double-counts subjects that relate to more than one entity. This is the collective-coverage cousin of the per-entity rule above: emit one row per selected entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"); a bare "what share … of these" is one collective value. - Complete the panel for "each / every / all / per
". These cues mean the answer's rows should be the full expected domain — every month in the asked range, every region in the dimension — not only the groups that happen to have fact rows; a plain innerGROUP BYemits only non-empty groups, so empty periods/categories silently drop and a "12 months" answer comes back short. Build the full set of groups (the spine),LEFT JOINthe aggregated facts onto it, then default the gaps:- Spine source. For a category, take the distinct domain from the dimension/entity table (e.g. every region from
regions) — notSELECT DISTINCTover the facts, which can only list categories that already occur; with no dimension table, distinct values from the unfiltered facts are the best available domain. For a period or number range, generate the series across the question's stated range (when the range is "all periods present", derive its bounds fromMIN/MAXover the unfiltered facts). Series syntax is engine-specific — get the series/calendar idiom fromsql_dialect_notesrather than inlining one dialect's generator. - Default by additivity.
COALESCE(metric, 0)only for additive measures (aCOUNT/SUMof events or amounts, where "no activity" genuinely reads as 0); leave non-additive measures (AVG, a rate, a ratio, a price, a running balance) asNULL— absence is "no data", and 0 would be a wrong reading. - Don't over-apply. each / every / all wants the complete domain; which / that have ("which months had orders") wants only the groups that exist — there the spine is wrong, so emit observed groups only.
- Selecting the extreme group needs the spine too. When you pick the group with the highest/lowest count or total over a period/category domain ("the month with the lowest number of active customers", "the region with the fewest orders"), rank over the COMPLETE spine, not only groups that have fact rows — an empty period/category is a genuine 0 and is frequently the true minimum, yet ranking over observed groups alone silently makes it unselectable and returns the wrong extreme. A period with NO rows at all never appears in a
GROUP BYof the facts: generate the full calendar of the stated range first ("each month of 2020" → all 12 months, even if only 4 have transactions),LEFT JOINthe per-group aggregates,COALESCEthe count to 0, and only THEN rank — otherwise a zero-activity month that is the true lowest is invisible to the ranking.
- Spine source. For a category, take the distinct domain from the dimension/entity table (e.g. every region from
- Answer every requested output. When a question asks for several things — a list ("A, B, and C"), paired extremes ("the highest and the lowest"), or a value plus its components ("X, Y, and their ratio") — the projection needs one column per requested output, not just the first clause. Why: answering only the first clause is the most common way a runnable query is still wrong — the grain and methodology can be perfect yet the answer is short by columns. This is the umbrella over the next two rules: keep the inputs is its "value + components" case and expose identity is its "entity identity" case, so a complete projection is exactly every requested metric/attribute, plus the identifier of each named entity, plus the inputs to each derived value, at the question's grain. It governs which columns appear — distinct from Top … and For each X above, which govern which rows — and composes with them ("highest and lowest per region" needs one row per region and a column per clause).
- Keep the inputs to a derived value. When the question asks for inputs and something derived from them ("X, Y, and their ratio"), project the inputs as columns alongside the derived value.
- A comparison BETWEEN two specific extremes is one wide row. When the question asks for a single value derived by comparing two named extremes — "the difference between the highest and the lowest month", "the ratio of the best to the worst" — present BOTH extremes side by side in ONE row: each extreme's attributes as their own columns (e.g.
highest_month,highest_value,lowest_month,lowest_value) plus the comparison as a column (difference). The comparison is a single fact about the pair, so the answer is one wide row — NOT one row per extreme with the comparison repeated. (Contrast: "report a metric for each group/category" — e.g. "a percentage for each helmet group", "the top player for each outcome" — has no cross-item comparison and stays long, one row per group.) - Project BOTH identity and label. When the result is per-entity, project the entity's identifier and its human-readable name together — whichever you grouped by, add the other. The id disambiguates duplicate names, and a consumer may legitimately expect either; supplying both is the safe, complete choice (a per-entity answer that gives only one is a frequent cause of an otherwise-correct result not matching).
- Diagnose empty results. When a result is unexpectedly empty, relax filters one at a time to find which predicate removed the rows instead of guessing.
- Spatial predicates ("within area / within N meters / inside this polygon / nearest"). When a question filters or relates rows by geography, use the engine's geospatial functions — get the exact ones from
sql_dialect_notes— rather than hand-rolling latitude/longitudeBETWEENboxes (which are wrong off the equator and ignore polygon shape). Recipe: (1) turn each location into a geography point with the point constructor — mind argument order, most take longitude before latitude; (2) for an area of interest build a polygon from its boundary/corner coordinates, closing the ring (first point repeated last); (3) test the relation with the engine's containment (contains/within), proximity (dwithin(g1,g2,meters)), or overlap (intersects) predicate. For "the features within the same area as entity X", first resolve X's own geometry in a CTE, then join candidates on the spatial predicate against it. Why: spatial relationships are not axis-aligned ranges; the geodesic predicates are both correct and index-assisted, while a raw coordinate box silently includes/excludes the wrong rows. - Collapse a multi-valued attribute to one representative per entity before counting classes or a concentration metric. When an entity carries a multi-valued classification array (IPC/CPC codes, tags, categories) and the methodology counts entities per class or computes a concentration/diversity measure (HHI, originality, a share), pick exactly one representative value per entity in a CTE first — use the array's
main/primary/firstflag when present, else a defined fallback (e.g. the most-frequent value) — then aggregate. Equally, when a metric's denominator is defined as a count of entities ("the number of patents cited"), useCOUNT(DISTINCT entity), not the count of exploded array rows. Why:LATERAL FLATTEN/unnest of the array multiplies an entity's weight by how many codes it has, inflating per-class frequencies and skewing any concentration metric — the query runs but the ranking/score is wrong. (Take the representative rule from the methodology/external_knowledgedoc when it specifies one; do not invent a selection the source does not state.) - Final completeness check. Before emitting the final SQL, re-read the question and confirm the projection covers: (1) every named metric / attribute asked for (→ answer every requested output); (2) the identifier of each grouped or named entity (→ expose identity); (3) every input to each derived value (→ keep the inputs); (4) all at the grain the question specifies (→ for each X / complete the panel). Run this on every query, not only when a result looks off. Don't over-project: anything outside that set — a column the question never asked for, added "to be safe" — adds noise, misleads the reader into thinking it matters, and makes the result harder to consume. Match the request exactly: neither short nor padded.
-- "How many orders per region, including regions with no orders?" — every region
-- must appear, even one with zero orders.
-- WRONG: grouping the facts can only emit regions that have at least one order,
-- so a zero-order region silently drops and the panel comes back short a row.
SELECT region_id, COUNT(*) AS n_orders
FROM orders
GROUP BY region_id;
-- RIGHT: start from the full region domain (the dimension table), LEFT JOIN the
-- per-region counts onto it, and COALESCE the additive count to 0 so empty
-- regions read 0 instead of vanishing.
WITH region_domain AS (
SELECT DISTINCT region_id FROM regions
),
region_orders AS (
SELECT region_id, COUNT(*) AS n_orders
FROM orders
GROUP BY region_id
)
SELECT d.region_id, COALESCE(ro.n_orders, 0) AS n_orders
FROM region_domain d
LEFT JOIN region_orders ro ON ro.region_id = d.region_id;
-- "For each region, report the highest and the lowest monthly order count and the
-- difference between them." A complete answer is five columns: the region's id and
-- name, the highest, the lowest, and their difference.
-- WRONG: answers only the first clause and drops the region id, the lowest, and the
-- difference — four of the five requested columns are missing.
SELECT region_name, MAX(monthly_orders) AS highest
FROM region_monthly
GROUP BY region_name;
-- RIGHT: one column per requested output plus the entity's identity, at the region
-- grain — id and name, the highest, the lowest, and their difference.
SELECT r.region_id, r.region_name,
MAX(rm.monthly_orders) AS highest,
MIN(rm.monthly_orders) AS lowest,
MAX(rm.monthly_orders) - MIN(rm.monthly_orders) AS order_count_range
FROM regions r
JOIN region_monthly rm ON rm.region_id = r.region_id
GROUP BY r.region_id, r.region_name;
</sql_craft>
Workflow:
dictionary_search({ values: ["Acme Corp"] })findscustomers.name.discover_data({ query: "orders customer monthly" })finds an orders semantic-layer source.sl_read_source({ connectionId: "warehouse", sourceName: "orders_facts" })confirms the source grain, measures, and dimensions.sl_query({ connectionId: "warehouse", measures: ["order_count"], filters: ["customer_name = 'Acme Corp'"] })answers through the semantic layer.memory_ingest({ connectionId: "warehouse", content: "Acme Corp order analysis used orders_facts.order_count filtered by customers.name = 'Acme Corp'. Source: current analysis turn." })captures the durable finding.
Input: "What columns does the events table have?"
Workflow:
discover_data({ query: "events table" })returns atableref.entity_details({ connectionId: "warehouse", entities: [{ table: "analytics.events" }] })returns columns, types, and foreign keys.- Answer directly. No query is needed.
Input: "Heads up: ARR is always reported in cents in our warehouse."
Workflow:
- If multiple connections exist, call
connection_listand identify the warehouse the user means. Ask if ambiguous. memory_ingest({ connectionId: "warehouse", content: "ARR is reported in cents (not dollars) in this warehouse. Multiply by 0.01 for dollar amounts. Source: user clarification." })remembers the warehouse-specific rule without running an analysis turn.
packages/cli/src/skills/dbt_ingest/SKILL.md
npx skills add Kaelio/ktx --skill dbt_ingest -g -y
SKILL.md
Frontmatter
{
"name": "dbt_ingest",
"callers": [
"memory_agent"
],
"description": "Map dbt `schema.yml` \/ `properties.yml` models and sources into ktx semantic-layer overlays and column notes. Covers `sources:` vs `models:`, column `data_tests` (not_null, unique, accepted_values, relationships), and how bundle-time writes complement manifest backfill from git sync. Load when the WorkUnit's `skillNames` includes `dbt_ingest` or when raw files are dbt YAML under `models\/` \/ `sources\/`."
}
dbt → ktx (bundle ingest)
Use this skill for uploaded dbt projects (dbt_project.yml at stage root, models/**, sources/**, schema.yml). There is no fetch() in v1 - scheduled dbt parse / manifest.json pulls are out of scope; host-provided dbt sync may still backfill structured test metadata into _schema on the next sync.
Mapping (models / sources → SL)
| dbt | ktx | Notes |
|---|---|---|
models: entry with columns: |
Overlay on the manifest table with the same name (after discover_data / entity_details) |
One SL source per physical table; model name may differ from DB name - resolve with read_raw_file + warehouse context. |
sources: → tables: |
Same as models; use identifier when present instead of logical name. |
Schema + name must match how the connection sees tables. |
Column description |
column_overrides[].descriptions.user on the overlay |
Do not overwrite dbt description keys from sync. |
data_tests: not_null / unique |
Short hint in column descriptions or notes: “dbt: not null”, “dbt: unique” |
Full structured metadata lands in manifest via sync; the skill keeps bundle-time SL text useful for the agent. |
accepted_values |
Add a brief line in the column description: allowed values (truncate long lists) | Also mention enum-like use in discover_data / filters. |
relationships |
Add or confirm joins: on the overlay only when to resolves to a real table via read_raw_file + discover_data / entity_details |
If the ref cannot be resolved, capture the intent in a wiki page instead. |
Physical schema grounding
dbt YAML is documentation and test metadata; it is not permission to invent physical columns. Before writing any table-backed SL source, confirm the real warehouse shape with discover_data, sl_discover, or entity_details and use only confirmed column names in column_overrides:, computed-only columns:, grain:, joins:, segments:, and measures[].expr.
For dbt context-source ingest, the dbt connection is usually not the warehouse connection. Call sl_discover without connectionId first, then write overlays to the connection that owns the matching manifest-backed source (for example postgres-warehouse), not to the dbt connection (for example dbt-main). If no matching manifest-backed source is visible on any warehouse connection, do not call sl_write_source; record emit_unmapped_fallback and keep the fact wiki-only.
If a models: entry has no columns: block, or the available raw files do not confirm the physical column names, do not synthesize a full standalone source. Write a wiki note or a description-only overlay for the resolved manifest table instead. If a business metric is described but its referenced column is not confirmed in the warehouse schema, omit the measure and capture the unresolved intent in the wiki.
Include rawPaths on every wiki_write, sl_write_source, and sl_edit_source call with only the dbt YAML files that directly support the action.
After every sl_write_source, call sl_validate. A validation error saying a declared column or measure reference is absent from the physical table is a hard stop: re-read the warehouse-backed source and rewrite with confirmed names, or remove the invalid SL fields.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
1.1 test hints (descriptions / meta)
When YAML shows accepted_values or not_null, add short hints into column_overrides[].descriptions (for example under user) or freeform column notes so chat and validation see intent before the next git sync refreshes constraints / enum_values in _schema. Keep hints under a few words when possible.
Overlap with MetricFlow
If the same bundle also has MetricFlow semantic_models: / metrics:, the metricflow_ingest skill owns semantic/metric shapes. This skill focuses on raw dbt schema YAML (models, sources, tests). If both apply, load metricflow_ingest first when the file is clearly MetricFlow; otherwise use dbt_ingest for schema.yml without semantic_models.
Do not
- Do not run
dbtCLI or assumetarget//manifest.jsonexists in the upload. - Do not invent column names, grain keys, or measure expressions from dbt model names, descriptions, tests, or common naming patterns.
- Do not write computed
columns:,column_overrides:,grain:, ormeasures:for a dbt model unless those exact column names are confirmed by dbt YAML columns or warehouse schema discovery. - Do not invent joins from
relationshipstests if the target model/table is not found in SL or the warehouse. - Do not read
peerFileIndexpaths - useread_raw_fileonly onrawFilesanddependencyPathsfrom the WorkUnit.
packages/cli/src/skills/gdrive_synthesize/SKILL.md
npx skills add Kaelio/ktx --skill gdrive_synthesize -g -y
SKILL.md
Frontmatter
{
"name": "gdrive_synthesize",
"callers": [
"memory_agent"
],
"description": "Synthesize durable KTX wiki pages from staged Google Drive document pulls. Load when a WorkUnit contains Google Doc raw files from `docs\/**`."
}
Google Drive Doc Synthesis
Use this skill when a WorkUnit contains staged Google Drive content from docs/**.
Role
Each WorkUnit is one Google Doc plus its metadata. Read the assigned raw files, then write a small set of durable wiki entries that capture reusable organizational knowledge. Write final memory directly; do not write candidates.
Required Workflow
- Read the WorkUnit notes and
rawFileslist. Document content lives inpage.md;metadata.jsonholds title, path, url, modified time, and Drive folder context. - For each assigned doc, call
read_raw_file, orread_raw_spanfor oversized docs when the notes specify a span. - Search
wiki_searchfor existing pages that overlap the WorkUnit topics. Prefer updating an existing page over creating a duplicate. - Use
context_evidence_search,context_evidence_read, andcontext_evidence_neighborswhen indexed document chunks would help reconcile related facts. PasschunkIdanddocumentIdvalues verbatim as returned by the evidence tools. - Write durable business knowledge with
wiki_write. Aim for a small number of high-quality pages per doc. IncluderawPathswith the exact Google Drive raw files that support each page. - If a doc references warehouse, dbt, Looker, Metabase, or MetricFlow objects, you may verify them with
discover_data,entity_details,sql_execution,sl_discover, orsl_read_source, but Google Drive docs are knowledge-only in v1. Do not create semantic-layer sources under thegdriveconnection. - For every deleted raw path in the Eviction Set, call
eviction_list, decide retention, thenemit_eviction_decision. Do this even when no wiki write is needed.
What To Capture
Capture durable, reusable company knowledge:
- policies, workflows, process rules, ownership conventions, and operating procedures
- product definitions, business terminology, and organizational guidance
- source-of-truth statements, caveats, conflict notes, and supersession guidance
- cross-system aliases that connect doc terminology to warehouse, dbt, Looker, Metabase, or MetricFlow names
Skip noisy or transient content:
- brainstorming notes with no durable rule
- task lists, meeting scheduling details, and time-bounded status updates
- duplicate docs with no new fact
- shallow summaries that add no reusable policy or definition
Quality
Prefer fewer, stronger entries. Every wiki entry must cite at least one Google Doc using its title or path and last modified date when available. When evidence conflicts, write a conflict note inside the wiki page rather than choosing silently.
If one doc covers several related ideas, synthesize the shared durable rules instead of writing one thin page per paragraph. For oversized spans, read only the assigned span unless the WorkUnit explicitly asks for neighboring context.
Search existing wiki pages for the same tables: or sl_refs: frontmatter and for source-of-truth aliases before creating a new page. If an existing page already documents the same warehouse object or business concept, update it instead of creating a differently named duplicate.
Citation Style
## Agentic Harness
- The harness provides the operational framework that turns an agent prototype into a production system.
- Source: Google Doc - Herness, last modified 2026-05-24.
- Conflict note: An older internal note uses a narrower definition focused only on tool wiring; treat the current Google Doc as the durable operating definition unless replaced explicitly.
Semantic-Layer Rules
- Google Drive docs are knowledge-only in v1; keep durable output in wiki pages.
- Do not create semantic-layer sources under the
gdriveconnection. - If a doc references an existing warehouse or semantic-layer object and you can verify it, you may attach
sl_refsin wiki output after confirmation. - If a doc mentions a table or source that cannot be verified, keep the identifier in wiki text as unverified or use
emit_unmapped_fallbackonly when the missing physical object itself is the important durable fact.
Identifier Verification Protocol
Before writing a wiki page on any topic:
discover_data({query: "<topic>"})- 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,
tables: frontmatter, sl_refs, or emit_unmapped_fallback:
entity_details({connectionId, targets: [{display: "<identifier>"}]})- confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.- For literal values from the doc, such as status codes or plan tiers,
check whether they appear in
entity_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Tools
Allowed: read_raw_file, read_raw_span, wiki_search, wiki_read, wiki_write, discover_data, entity_details, sql_execution, sl_discover, sl_read_source, context_evidence_search, context_evidence_read, context_evidence_neighbors, emit_unmapped_fallback, eviction_list, emit_eviction_decision.
Not allowed: context_candidate_write, context_candidate_mark, sl_write_source, sl_edit_source, sl_validate.
packages/cli/src/skills/historic_sql_patterns/SKILL.md
npx skills add Kaelio/ktx --skill historic_sql_patterns -g -y
SKILL.md
Frontmatter
{
"name": "historic_sql_patterns",
"callers": [
"memory_agent"
],
"description": "Identify recurring cross-table historic-SQL analytical intents from a bounded pattern shard and emit typed pattern evidence for deterministic wiki projection."
}
Historic SQL Patterns
Use this skill when the WorkUnit raw file is a patterns-input/part-0001.json style shard from the historic-sql adapter. Older staged bundles may still provide root patterns-input.json; when that is the WorkUnit raw file, read it the same way.
Required Workflow
- Read the WorkUnit notes first.
- Find the single pattern input file listed under the WorkUnit
rawFilessection. - Call
read_raw_filefor that exact raw file path. - Identify recurring analytical intents that span at least two tables and have repeated usage signal.
- Emit one
patternevidence object per durable cross-table intent by callingemit_historic_sql_evidence. - Stop after all pattern evidence has been emitted.
Every join column mentioned in pattern descriptions must be verified via entity_details for both sides of the join.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Evidence Shape
Each call to emit_historic_sql_evidence must use this shape:
{
"kind": "pattern",
"pattern": {
"slug": "order-lifecycle-analysis",
"title": "Order Lifecycle Analysis",
"narrative": "Analysts compare order statuses with customer segments to understand lifecycle movement.",
"definitionSql": "select o.status, count(*) from public.orders o join public.customers c on c.id = o.customer_id group by o.status",
"tablesInvolved": ["public.orders", "public.customers"],
"slRefs": ["orders", "customers"],
"constituentTemplateIds": ["pg:1", "pg:2"]
}
}
The pattern object must match patternOutputSchema; multiple calls together must form patternsArraySchema.
Pattern Selection Rules
- Prefer patterns that involve two or more tables.
- Prefer templates with
executionsBucketat least10-100anddistinctUsersBucketabove solo usage. - Merge templates into one pattern only when the business intent is the same.
- Use a stable kebab-case slug based on intent, not a template id.
- Set
definitionSqlto the clearest representative SQL from a constituent template. - Set
slRefsto source names when the source name is obvious from table names; omit uncertain refs rather than guessing. - Treat each pattern shard independently; do not read peer shard files from
peerFileIndex.
Boundaries
- Do not call wiki_write.
- Do not call sl_write_source.
- Do not call sl_edit_source.
- Do not call context_candidate_write.
- Do not create single-table pattern pages.
- Do not copy credentials, tokens, user emails, or unredacted literals into evidence.
packages/cli/src/skills/historic_sql_table_digest/SKILL.md
npx skills add Kaelio/ktx --skill historic_sql_table_digest -g -y
SKILL.md
Frontmatter
{
"name": "historic_sql_table_digest",
"callers": [
"memory_agent"
],
"description": "Convert one changed historic-SQL table usage bucket into typed table usage evidence for deterministic _schema projection."
}
Historic SQL Table Digest
Use this skill when the WorkUnit raw file is one tables/<schema>.<name>.json file from the historic-sql adapter.
Required Workflow
- Read the WorkUnit notes first.
- Call
read_raw_filefor the singletables/<schema>.<name>.jsonraw file. - Read
manifest.jsononly if the table JSON omits the dialect or the WorkUnit notes are unclear. - Produce one concise usage narrative for this table from the staged table JSON.
- Call
emit_historic_sql_evidenceexactly once withkind: "table_usage". - Stop after the evidence tool succeeds.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Evidence Shape
Call emit_historic_sql_evidence with this shape:
{
"kind": "table_usage",
"table": "public.orders",
"usage": {
"narrative": "Orders are repeatedly queried for paid/refunded lifecycle analysis and customer-level rollups.",
"frequencyTier": "high",
"commonFilters": ["status", "created_at"],
"commonGroupBys": ["status"],
"commonJoins": [{ "table": "public.customers", "on": ["customer_id"] }],
"staleSince": null
}
}
The usage object must match tableUsageOutputSchema.
Interpretation Rules
- Treat
columnsByClause.whereas common filters. - Treat
columnsByClause.groupByas common group-bys. - Treat
observedJoinsas common joins. - Use
stats.executionsBucket,stats.distinctUsersBucket, andstats.recencyBucketto choosefrequencyTier. - Use
frequencyTier: "high"only when executions and distinct users are both broad. - Use
frequencyTier: "mid"for repeated team usage that is not broad enough for high. - Use
frequencyTier: "low"for low-volume but present usage. - Use
frequencyTier: "unused"only when the table input explicitly says the table is stale or has no recent templates. - Keep
narrativeshort and concrete.
Boundaries
- Do not call wiki_write.
- Do not call sl_write_source.
- Do not call sl_edit_source.
- Do not call context_candidate_write.
- Do not emit more than one table usage evidence object.
- Do not invent columns, joins, or tables that are absent from the staged JSON.
packages/cli/src/skills/ingest_triage/SKILL.md
npx skills add Kaelio/ktx --skill ingest_triage -g -y
SKILL.md
Frontmatter
{
"name": "ingest_triage",
"callers": [
"memory_agent"
],
"description": "Classify and resolve conflicts detected during bundle ingest (structural duplicates, definitional contradictions, near-duplicate clusters, re-ingest changes, evictions)."
}
Ingest Triage - conflict classification and resolution
This skill is loaded in two contexts:
- By a Stage 3 WorkUnit agent when
sl_discover, deterministic projection output, existing project memory, or prior provenance overlaps with what the current WorkUnit is about to write. - By the Stage 4 reconciliation agent for cross-WorkUnit sweeps, accepted patch overlap, and eviction decisions.
Apply the rules below before every write that could collide with an existing artifact.
Decision tree
-
Is this the same artifact I'm producing now, or a different one with the same name? Read both. If names match and content matches (modulo whitespace): no conflict - skip the write, the prior one stands.
-
If content differs, is it an expression-only change (e.g. a different
sql:body for the same measure name, same grain, same columns)? Re-ingest change (expression-only): silently replace viasl_edit_source. No flag. -
If the difference is structural - grain, columns, filter, join shape - is the current bundle the re-ingest of a previously-ingested bundle (i.e.
priorProvenancehas a row for this raw file and artifact)? Re-ingest change (semantic break): replace + flag. Record in the IngestReport'sconflicts_resolvedlist withflagged_for_human: true. -
If reconciliation sees accepted patches from this same job with no prior-sync row, check for same-ingest contradictions:
Kind Detection Resolution Structural duplicate Same name, near-identical expression Elect canonical by: (a) highest inbound-ref count from other sources; tiebreak: (b) lexicographically first unit key; (c) lexicographically first source name. Subsume losers into <canonical>-variants.mdwiki page. Do NOT flag unless ambiguous.Near-duplicate cluster Different names, overlapping shape (same table, similar formulas) Same as structural; one canonical, others subsumed. Flag only if no canonical emerges. Definitional contradiction Same name, substantively different formulas (different aggregation, different filters, different columns) Rename + capture: disambiguate ALL variants with suffix derived from the domain ( churn_risk_engagement_based,churn_risk_billing_based) and write a unified wiki page listing every variant with provenance. The contested name does NOT land in the SL. Always flag. -
Eviction (Stage 4 only): for each entry in
eviction_list():- Remove the artifact (
sl_write_sourceorsl_edit_sourcewithdelete: truefor SL sources,wiki_removefor wiki pages). - Record the removal with
emit_eviction_decisionandaction: "removed".
- Remove the artifact (
Why same-ingest vs re-ingest differs
Within ONE bundle there's no user signal telling us which duplicate wins - we capture all variants and flag. Across bundles, re-uploading IS the signal that the new state is intended - we replace silently for expression changes and flag for semantic breaks.
Naming disambiguation hints
When you rename to disambiguate, prefer domain suffixes that match the containing view/table/collection name: customers.churn_risk_score → customers.churn_risk_engagement_based (if the customer_churn view computes it from engagement); billing.churn_risk_score → billing.churn_risk_billing_based. Avoid numeric suffixes (churn_risk_1, churn_risk_2) - they disclose nothing.
Applying canonical pins
When the Stage 4 system prompt includes a <canonical_pins> block, treat each pin as a prior user decision for that contestedKey.
- If the pinned
canonicalArtifactKeyis present in the Stage Index or already exists in SL, keep it as the canonical artifact for that contested key. - Disambiguate competing artifacts instead of using the contested name for them.
- Do not flag the pinned contested key solely because the variants disagree; the user has already chosen the canonical artifact.
- If the pinned artifact cannot be found and no current WU can recreate it, emit
emit_conflict_resolutionwithflaggedForHuman: trueand explain that the pin references a missing canonical artifact.
When a pin applies cleanly, call emit_conflict_resolution with kind: "definitional_contradiction", artifactKey set to the pinned canonicalArtifactKey, detail describing the pinned election, and flaggedForHuman: false.
What to write in the unified wiki page
When you perform rename + capture, also write one page named <canonical-concept>-definitions.md under the wiki GLOBAL scope. Structure:
- One heading per variant, referencing the disambiguated SL name.
- One paragraph per variant: what it computes, where it came from (raw file + line range), when to use it.
- A closing "Choosing between these" paragraph if the variants are legitimately domain-specific.
Do not attempt to rank variants or pick a "best" - that's user-override territory.
Silence rules
Flag for human review when:
- You did rename + capture for a definitional contradiction (kind 3 above).
- You performed an eviction retention (kind 5, second row).
- An override constraint (from a Stage 4 re-run) conflicts with current inbound refs.
Do NOT flag:
- Same-content duplicate skip (trivial).
- Structural duplicate with clear canonical election.
- Expression-only re-ingest replace.
packages/cli/src/skills/live_database_ingest/SKILL.md
npx skills add Kaelio/ktx --skill live_database_ingest -g -y
SKILL.md
Frontmatter
{
"name": "live_database_ingest",
"callers": [
"memory_agent"
],
"description": "Capture semantic-layer and knowledge updates from a live database schema snapshot."
}
Live Database Ingest
Use this skill when the ingest work unit contains raw files under
raw-sources/<connectionId>/live-database/<syncId>/.
Workflow
- Read the table JSON file listed in the work unit.
- Read
connection.jsonto understand the snapshot metadata. - Read
foreign-keys.jsonwhen the table has a foreign key or when joins are needed for the semantic-layer source. - Create or update one semantic-layer source for the table with
sl_write_source. - Use the physical table name from the raw JSON as the source
tablefield. - Preserve database comments as
descriptions.dbon tables and columns. - Add joins only when the foreign key index names both sides.
- Write wiki pages only for durable business meaning that is present in table or column comments.
- Run
sl_validatefor the table source before the work unit completes.
Sample values come from the scan record; do not invent values not present in relationship-profile.json.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Source shape
For a raw table with this shape:
{
"name": "orders",
"db": "public",
"columns": [
{ "name": "id", "type": "integer", "nullable": false, "primaryKey": true }
]
}
Write a semantic-layer source with this shape:
name: orders
table: public.orders
grain: id
columns:
- name: id
type: number
Use string, number, time, or boolean for column types. When a database
type is ambiguous, use string.
Boundaries
The raw snapshot is structural evidence. Do not invent measures, segments, business definitions, or joins that are not present in the snapshot files.
packages/cli/src/skills/looker_ingest/SKILL.md
npx skills add Kaelio/ktx --skill looker_ingest -g -y
SKILL.md
Frontmatter
{
"name": "looker_ingest",
"callers": [
"memory_agent"
],
"description": "Extract durable ktx knowledge and semantic-layer contribution proposals from staged Looker runtime dashboard, Look, and explore JSON. Load for WorkUnits whose raw files are under explores\/, dashboards\/, or looks\/."
}
Looker Runtime Ingest
Looker runtime ingest turns API-staged dashboards, Looks, and explores into durable ktx memory. Runtime entities are evidence. They are not themselves the final knowledge shape.
Required Workflow
- Read every
rawFilesentry for the WorkUnit. - Read relevant
dependencyPathsbefore making a decision. For dashboard and Look WUs this usually includes the referenced explore JSON, signal files,folders/tree.json, andusers/<id>.json. - Treat
signals/*.json, owners, folders, schedules, and favorites as prioritization or provenance context only. - Extract generalizable metric formulas, segment definitions, field semantics, and domain conventions.
- Use
wiki_search,sl_discover, andsl_read_sourcebefore writing so new content merges with existing memory instead of duplicating it. - Use
context_evidence_searchorcontext_evidence_readto obtain evidence chunk IDs for any wiki-bound knowledge candidate. - Use
context_candidate_writefor durable wiki-bound knowledge. Do not callwiki_writefrom a Looker WorkUnit; Stage 4 reconciliation promotes candidates and writes wiki pages. - Use
looker_query_to_slfor each Look query or dashboard tile query that has aqueryobject. - Write SL from Looker runtime evidence only through the staged warehouse target contract. For explores and inherited dashboard/Look queries, branch on
targetTable.ok; when it is true, write ontargetWarehouseConnectionIdand usetargetTable.canonicalTableassource.table. When it is false or missing, write wiki knowledge candidates and recordemit_unmapped_fallbackwith the staged reason. - Run
sl_validateafter every SL write. If validation fails, fix the source or roll it back before the WorkUnit ends.
For every Looker field reference, call entity_details on the underlying schema.table.column before promoting it to sl_refs or quoting it in wiki body.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Explore WorkUnits
Explore WUs have raw files like explores/<model>/<explore>.json and usually depend on lookml_models.json.
Use the deterministic API-derived source key:
looker__<model>__<explore>
For example, modelName: "b2b" and exploreName: "sales_pipeline" map to looker__b2b__sales_pipeline.
Mapped explore write shape:
{
"connectionId": "22222222-2222-4222-8222-222222222222",
"sourceName": "looker__b2b__sales_pipeline",
"source": {
"name": "looker__b2b__sales_pipeline",
"table": "proj.dataset.opportunities",
"grain": ["opportunity_id"],
"columns": [
{
"name": "opportunity_id",
"type": "string"
},
{
"name": "arr",
"type": "number"
}
],
"measures": [
{
"name": "total_arr",
"expr": "sum(arr)"
}
]
}
}
Every concrete value in that example must be backed by raw Looker field SQL, source_tables preflight, source_columns, or existing SL when applied to a real WorkUnit. If the evidence is not present, write wiki candidates and emit emit_unmapped_fallback.
The staged explore file carries warehouse target fields populated before the WU starts:
connectionName: the Looker runtime connection name.targetWarehouseConnectionId: the resolved warehouse connection id, ornullwhen the Looker connection is unmapped.rawSqlTableName: Looker's verbatimsql_table_name. Keep it as provenance only.targetTable: the parsed target-table union. Use this as the sole branch condition.
When targetTable.ok === true, the explore has a complete ktx backing target. Before writing:
- Use
targetTable.catalog,targetTable.schema, andtargetTable.nameforsource_tablespreflight matching throughsl_discoverorsl_read_source. - Use Looker field
sql, labels, descriptions, and type metadata to derive source columns, measures, segments, joins, and grain. - Call
sl_write_sourceorsl_edit_sourcewithconnectionId: targetWarehouseConnectionIdandrawPathsset to the staged explore path. - Set
source.nameto the deterministic API-derived source key, for examplelooker__b2b__sales_pipeline. - Set
source.tabletotargetTable.canonicalTable. - Run
sl_validateafter every SL write.
The table field is targetTable.canonicalTable, not rawSqlTableName. Raw Looker values can contain aliases such as schema.table AS x, Looker templates such as ${TABLE}, or derived-table SQL. Those raw forms do not compose safely with SL generation. targetTable.canonicalTable is the dialect-quoted identifier rebuilt by the parser.
Use targetTable.{catalog,schema,name} only for source_tables preflight. Do not put those tuple fields separately into the SL source unless the SL schema already asks for them.
When targetTable.ok === false, keep the WU wiki-only for SL purposes. Capture durable domain semantics with context_candidate_write, then emit a fallback with the EXACT structured reason code from targetTable.reason. Put any human-readable context in clarification, NOT in reason:
{
"rawPath": "explores/b2b/sales_pipeline.json",
"reason": "no_connection_mapping",
"clarification": "Looker connection b2b_sandbox_bq is not mapped to a warehouse connection",
"fallback": "wiki_only"
}
Valid reason codes (use exactly one, no other strings allowed): no_connection_mapping, looker_template_unresolved, derived_table_not_supported, no_physical_table, multiple_table_references, unsupported_dialect, parse_error, missing_target_table.
When targetTable is null, read the raw explore file again. If the target is still absent, emit the same fallback with "reason": "missing_target_table".
Look And Dashboard WorkUnits
Looks have raw files like looks/<id>.json. Dashboards have raw files like dashboards/<id>.json. Dashboard tiles with inline query objects follow the same decision rules as Looks.
For each query:
- Call
looker_query_to_slwith the query JSON, title, content type, and usage counts if available. - Read the proposal's
targetStatus,targetWarehouseConnectionId,targetTable,sourceTable, andcanWriteStandaloneSource. - If
canWriteStandaloneSourceis true, usetargetWarehouseConnectionIdfor SL tools andsourceTable/targetTable.canonicalTableas the source table. Verify the proposal against the parent explore dependency and existing SL before writing. - If the proposal decision is
measure_added, add or edit a measure only after verifying the expression against the explore field SQL or an existing source column. - If the proposal decision is
source_created, create a source only whencanWriteStandaloneSourceis true and the filter is canonical. Usesource.table = targetTable.canonicalTable. - If
targetStatusisunmapped,unparseable, ormissing_target_table, keep SL wiki-only for this query and callemit_unmapped_fallbackwith the proposal's target reason or status. - If the proposal decision is
wiki_only, write a context candidate only when the Look or dashboard names a reusable business concept.
Capture Rules
Write SL for:
- reusable aggregations with clear formulas;
- reusable segment predicates that appear canonical;
- calculated dimensions that are stable and backed by raw Looker query evidence;
- joins or source relationships that are explicit in the explore JSON.
Write wiki for:
- metric definitions in dashboard or Look titles, descriptions, axis labels, and filter semantics;
- business meaning of an explore;
- concept aliases used by teams;
- caveats about multiple competing definitions.
Skip:
- point-in-time values and chart screenshots;
- dashboard layout, tile positions, colors, visualization types, and render settings;
- owner names, top users, recipient counts, favorite counts, schedules, and usage counts as narrative content;
- ad-hoc low-usage queries with no durable business semantics;
- simple saved views of fields with no metric, segment, or concept definition.
Usage Signals
Use usage only to prioritize:
- zero or near-zero usage lowers priority and often means skip;
- high usage raises confidence that a metric or segment is canonical;
- schedules and favorites can break ties between otherwise similar candidates.
When calling context_candidate_write, usage can affect scoring:
- High usage (
queryCount30d >= 10oruniqueUsers30d >= 3) can justifyauthorityScore: 3andreuseScore: 3when the evidence is otherwise durable. - Zero recent usage should usually use
actionHint: "skip"or lowerreuseScoreunless the content clearly defines a canonical business concept. - Schedules and favorites can raise
reuseScoreby 1 when deciding between otherwise similar candidate scores.
Never include the usage counts themselves in assertion, rationale, or eventual wiki prose.
Never write usage numbers, owner names, folder names, top users, schedule counts, or recipients into wiki article prose. If attribution is needed, keep it in provenance through the normal ingest action trail.
Provenance And Cross-References
When writing candidates from Looker evidence, cite chunk IDs from context_evidence_search or context_evidence_read. Stage 4 reconciliation writes wiki pages from promoted candidates and sets sl_refs when the source exists or was created in the run.
When an SL action is written on targetWarehouseConnectionId, the runner records targetConnectionId on the action and syncs knowledge_sl_refs to the warehouse connection. The wiki article still belongs to the Looker run connection; the SL ref belongs to the warehouse. Do not rewrite the source name or connection id in wiki frontmatter by hand. Use normal SL tool calls and let Stage 4 reconcile refs from actions.
Use these source-key conventions:
- API-derived explore source:
looker__<model>__<explore> - API-derived segment source:
looker__<explore>__<slug> - File-adapter source, when present:
<model>__<explore>without thelooker__prefix
During Stage 4 reconciliation, when both looker__<model>__<explore> and <model>__<explore> exist for the same connection, treat the unprefixed file-adapter source as canonical. Rewrite wiki sl_refs to the unprefixed source, remove the API-derived source if it was created in this run, and call emit_artifact_resolution with actionType: "subsumed", artifactKind: "sl", artifactKey: "looker__<model>__<explore>", and the raw explore path that produced it.
If a file-adapter source already exists and clearly subsumes the API-derived source, prefer the file-adapter source in sl_refs and mention the API entity only as evidence in the wiki content.
Examples
Measure proposal from a Look:
{
"title": "Open Pipeline ARR",
"query": {
"model": "b2b",
"view": "sales_pipeline",
"fields": ["opportunities.arr", "opportunities.stage"],
"filters": { "opportunities.stage": "open" }
}
}
Expected handling:
- call
looker_query_to_sl; - verify
opportunities.arrandopportunities.stageagainst the explore dependency and existing SL; - add or update a measure only if the resulting expression validates;
- write wiki for the durable definition "open pipeline ARR" if it is not already captured;
- avoid mentioning query counts or users in wiki prose.
Simple saved view:
{
"title": "Accounts By Region",
"query": {
"model": "b2b",
"view": "accounts",
"fields": ["accounts.region", "accounts.segment"],
"filters": {}
}
}
Expected handling:
- no SL write;
- wiki only if the title or description defines a reusable company concept;
- otherwise skip.
packages/cli/src/skills/lookml_ingest/SKILL.md
npx skills add Kaelio/ktx --skill lookml_ingest -g -y
SKILL.md
Frontmatter
{
"name": "lookml_ingest",
"callers": [
"memory_agent"
],
"description": "Map a LookML view\/model\/explore into ktx semantic layer sources. Covers the LookML to ktx primitive table, provenance tagging, and three worked examples (overlay, standalone from derived_table, standalone with sql_always_where). Load when the turn contains `.lkml` content."
}
LookML to ktx Semantic Layer
LookML views map to SL sources, measure: to measures, explore: { join: } to the join graph. This skill lays out the mapping and the three capture shapes.
Mapping table
| LookML | ktx form | Notes |
|---|---|---|
view: X { sql_table_name: …; measure:/dimension:/join: } |
Overlay named X with measures, computed-only columns, column_overrides, joins, segments |
Manifest-backed; inherit grain/columns |
view: X { derived_table: { sql: … } } |
Standalone with top-level sql:, explicit grain: + columns: |
No manifest entry exists |
view: X { sql_always_where: <p> } |
Standalone with sql: SELECT * FROM <base> WHERE <p> |
Enforcement, not opt-in |
explore: { join: Y { sql_on: …; relationship: … } } |
joins: entry { to: Y, on: "<local> = Y.<col>", relationship: … } |
On the overlay or standalone |
conditionally_filter / always_filter |
segments: [{ name, expr }] |
Callers reference by name |
| Manifest entry | _schema/*.yaml |
Never edit - auto-imported |
Type map: date/datetime/timestamp → time; yesno → boolean; number → number; string → string. Ignore drill_fields: (UI only).
Decision rules
LookML writes target the run connection directly. Unlike Looker runtime ingestion, the LookML adapter is configured on the warehouse ktx connection, so do not look for targetWarehouseConnectionId and do not route through a mapping array.
Before any SL write, inspect the WorkUnit notes.
If notes contain:
[LOOKML SL WRITES DISALLOWED]
reason: lookml_connection_mismatch
...
[/LOOKML SL WRITES DISALLOWED]
this is a hard gate. The model's declared Looker connection: does not match the warehouse connection's configured expectedLookerConnectionName. Continue wiki extraction and context candidates. Do not call sl_write_source or sl_edit_source for that WorkUnit. The runner also removes those write tools for this WorkUnit; treat the missing tools as expected. Preserve the mismatch reason in any emit_unmapped_fallback you create.
When SL is allowed:
- Overlay when the view is a thin wrapper over a manifest table (
sql_table_name:matches a manifest entry). Do not repeat base columns or grain. - Standalone when the view uses
derived_table:orsql_always_where:.sl_write_sourcerejects overlays whose name has no manifest entry; that error points here. - Skip a view with only
view:,sql_table_name:, and baredimension:entries (nomeasure:,description:,derived_table:,sql_always_where:,join:). The pre-filter already short-circuits those. - Include
rawPathson everysl_write_source/sl_edit_sourcecall with the exact LookML raw file(s) that support the action.
Preflight: never guess column names
LookML's dimension_group: date { type: time; timeframes: [raw, date, week, month] } expands at Looker-render time into ${view.date_raw}, ${view.date_date}, ${view.date_week}, and so on. These are NOT physical warehouse columns. The physical column is whatever the group's sql: clause references (e.g. ${TABLE}.date → column date).
A prior replay hallucinated date_date, date_week into sql:, columns:, and grain: across 4+ standalones; every measure on each affected source returned 400 Unrecognized name: date_date at query time. Preventable.
Verify each sql_table_name from the LookML view with entity_details before mapping to an SL source.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Required flow before writing any overlay or standalone:
- Call
sl_discover({ query: "<tableName>" })for each base table you're about to touch. That returns the real columns. - If the table isn't in the manifest, use the warehouse
connectionIdreturned bydiscover_dataor the target connection chosen fromsl_discover, then call a dialect-appropriate SQL probe with that connection id, for example:sql_execution({connectionId: "warehouse", sql: "SELECT 1 FROM analytics.orders LIMIT 0"}). Replacewarehouse,analytics, andorderswith the verified connection, schema or dataset, and table from the WorkUnit evidence. - Use only those names in
sql:,columns:, andgrain:. Map eachdimension_groupto ONE{ name: <physical_col>, type: time, role: time }entry - never one per timeframe.
| LookML input | ktx columns: entry |
|---|---|
dimension_group: month { type: time; timeframes: [month]; sql: ${TABLE}.month_date ;; } |
{ name: month_date, type: time, role: time } |
dimension_group: date { type: time; timeframes: [raw, date, week, month]; sql: ${TABLE}.date ;; } |
{ name: date, type: time, role: time } - single entry, NOT date_raw/date_date/date_week |
After every sl_write_source: call sl_validate. It runs SELECT * FROM (<your sql:>) LIMIT 0 against the connection. If a column name was invented, the warehouse's Unrecognized name: … error comes back verbatim. Treat that as a hard failure - re-read the real columns with sl_discover and rewrite.
Provenance markers
When a wiki mixes LookML source prose with sl_discover output, tag sections:
<!-- from: lookml -->
Customers fan out many-to-one into `accounts` via `account_id`.
<!-- /from -->
<!-- from: bq_schema -->
`customers.admin_user_id` is nullable - orphan rows exist.
<!-- /from -->
Invisible in most renderers; lets a future pass audit provenance.
Example 1 - overlay (thin wrapper)
LookML (excerpt):
view: fct_labs {
sql_table_name: analytics.fct_labs ;;
dimension: is_byol { type: yesno; sql: ${TABLE}.lab_type = 'byol' ;; }
measure: count_lab_orders { type: count; description: "Total lab orders." }
measure: count_byol_labs { type: count; filters: [is_byol: "yes"] }
}
explore: fct_labs {
join: dim_customers { sql_on: ${fct_labs.admin_user_id} = ${dim_customers.admin_user_id} ;; relationship: many_to_one }
}
ktx overlay at <connId>/fct_labs.yaml:
name: fct_labs
descriptions:
user: "Lab-order fact table. One row per lab order event."
columns:
- name: is_byol
type: boolean
expr: "lab_type = 'byol'"
measures:
- name: count_lab_orders
expr: count(lab_order_id)
description: Total lab orders.
- name: count_byol_labs
expr: count(lab_order_id)
filter: "is_byol = true"
joins:
- to: dim_customers
on: "admin_user_id = dim_customers.admin_user_id"
relationship: many_to_one
Example 2 - standalone from derived_table
view: lab_results {
derived_table: { sql:
SELECT lab_order_id, admin_user_id, lab_date, biomarker, value,
value - LAG(value) OVER (PARTITION BY admin_user_id, biomarker ORDER BY lab_date) AS delta
FROM analytics.raw_lab_results WHERE status = 'final' ;; }
dimension: lab_order_id { primary_key: yes; type: string }
measure: avg_delta { type: average; sql: ${delta} ;; }
}
name: lab_results
description: "Lab results with biomarker delta vs previous reading per user."
source_type: sql
sql: |
SELECT lab_order_id, admin_user_id, lab_date, biomarker, value,
value - LAG(value) OVER (PARTITION BY admin_user_id, biomarker ORDER BY lab_date) AS delta
FROM analytics.raw_lab_results WHERE status = 'final'
grain: [lab_order_id]
columns:
- { name: lab_order_id, type: string }
- { name: admin_user_id, type: string }
- { name: lab_date, type: time, role: time }
- { name: biomarker, type: string }
- { name: value, type: number }
- { name: delta, type: number }
measures:
- { name: count_lab_results, expr: "count(lab_order_id)" }
- { name: avg_delta, expr: "avg(delta)" }
Example 3 - standalone with sql_always_where
view: rpt_daily_braze_email {
sql_table_name: analytics.fct_email_sends ;;
sql_always_where: ${TABLE}.channel = 'braze' AND ${TABLE}.status = 'delivered' ;;
dimension: send_id { primary_key: yes; type: string }
measure: delivered_count { type: count }
}
name: rpt_daily_braze_email
description: "Delivered Braze email sends (enforced filter: channel='braze', status='delivered')."
source_type: sql
sql: |
SELECT * FROM analytics.fct_email_sends
WHERE channel = 'braze' AND status = 'delivered'
grain: [send_id]
columns:
- { name: send_id, type: string }
- { name: admin_user_id, type: string }
- { name: sent_at, type: time, role: time }
measures:
- { name: delivered_count, expr: "count(send_id)" }
sql_always_where is enforcement → wrap into the sql:. Don't model it as a segment (segments are opt-in) or per-measure filter (fragile, duplicated).
packages/cli/src/skills/metabase_ingest/SKILL.md
npx skills add Kaelio/ktx --skill metabase_ingest -g -y
SKILL.md
Frontmatter
{
"name": "metabase_ingest",
"callers": [
"memory_agent"
],
"description": "Convert Metabase questions, models, and metrics into ktx Semantic Layer source definitions. Covers result-metadata to KSL column type mapping, FK\/PK detection, near-duplicate deduplication, pre-aggregation decomposition, join-graph connectivity, and how to react to priorProvenance from earlier ingest syncs. Load when the WorkUnit contains `cards\/<id>.json` files under a Metabase bundle."
}
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/<id>.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/<id>.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", <field_id>, <options>]. 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: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>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/<id>.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: <manifest_key>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.
- Simple aggregation on a table that already has a source →
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: <SOURCE_NAME>
measures:
- name: <measure_name>
expr: "<expression>"
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: ["<on-clause>"] 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: '<table>' }) before writing. The matching manifest entry's name is the value you use in joins: [- to: <name>] 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 < '2021-01-01' AND status = 'placeholder'
For each listed placeholder: locate the WHERE clause(s) in the SQL that reference the dummy literal and drop them, then strip the warning comment. SL chat-time filters compose narrowing predicates dynamically, so the source should represent the unfiltered dataset.
For fallback cards, dropping is simpler - the SQL still has the [[ ... ]] brackets and {{ var }} placeholders intact:
-- before:
WHERE 1=1
[[AND {{ auction_end }} ]]
[[AND status = {{ status }} ]]
-- after:
WHERE 1=1
Step B - Inline {{#N}} references (fallback cards only)
Resolved cards already have {{#N}} inlined for you. For fallback cards, each {{#N}} (or {{#N-some-slug}}) in the SQL refers to another card's resolvedSql. The referenced card is in the WU's rawFiles or dependencyPaths. Read it with read_raw_file, then inline its SQL.
If the reference has an alias (from {{#5996-listing-interactions}} tb), the outer SQL probably uses that alias (select tb.* ..., tb.column_name, etc.). When you inline, you must EITHER:
- Pick a single base table inside the inlined SQL and rename its alias to the outer alias. Useful when the inlined card is
SELECT * FROM listings JOIN ...- set the LISTINGS alias totbandtb.*keeps working in the outer query. - Replace the outer alias references with explicit columns from the inlined SQL. Useful when the inlined card has multiple JOINs and
tb.*is ambiguous.
Never leave the outer alias dangling: after inlining, grep your SQL for the outer alias name and rewrite or remove every reference. A leftover tb.* with no tb table is the most common failure mode here.
Step C - Inlining cleanup checklist
After Steps A and B, your SQL must:
- Contain no placeholder-warning comment, no
{{,}},[[, or]]characters anywhere. - Reference no aliases that aren't defined inside the SQL itself.
- Be valid as a standalone subquery (the validator runs
SELECT * FROM (your_sql) LIMIT 1).
If resolutionStatus: "fallback" and the SQL is still complex enough that you can't confidently translate it, skip the card rather than writing broken SQL. Call emit_unmapped_fallback with the staged card path as rawPath, reason: "parse_error", clarification: "metabase_sql_untranslated", and fallback: "flagged".
Join-graph connectivity
For source_type: table:
- Use FK columns (
semantic_type: type/FK) to declaremany_to_onejoins to dimension sources. - Match column names ending in
_idagainst existing sources' grain columns.
For source_type: sql:
- The validator parses your SQL and rejects the write when a referenced manifest table has a viable projected local key but no declared
joins:entry. Add the join only after confirming the output key and target grain match. - If
sl_discoverresolves the table, it is not outside the manifest. Do not write anunmapped-table-*fallback for resolvedorbit_raw,mart, or other manifest-backed sources just because they appear inside card SQL. - If
sl_discovercannot resolve a referenced table at all, write a single-linewiki_writewith keyunmapped-table-<table_name>andrawPaths: ["cards/<id>.json"]so the gap is documented, then callemit_unmapped_fallbackwith the staged card path asrawPath,reason: "missing_target_table",tableRef: "<table_name>", andfallback: "wiki_only". Do not use this fallback ifsl_discoverresolved 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: ["<on-clause>"] 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/<id>.json files they derive from:
# <!-- from: raw-sources/<connId>/metabase/<syncId>/cards/7.json -->
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.
packages/cli/src/skills/notion_synthesize/SKILL.md
npx skills add Kaelio/ktx --skill notion_synthesize -g -y
SKILL.md
Frontmatter
{
"name": "notion_synthesize",
"callers": [
"memory_agent"
],
"description": "Synthesize durable ktx wiki pages and semantic-layer sources from staged Notion pages, databases, data-source rows, and clustered Notion evidence. Load when a WorkUnit contains Notion raw files or Notion evidence chunks."
}
Notion Cluster Synthesis
Use this skill when a WorkUnit contains staged Notion content from pages/**, databases/**, data-sources/**, or clustered Notion evidence.
Role
Each WorkUnit is either a single Notion page/span or a topical cluster of related Notion pages, pre-grouped by embedding similarity. Read the assigned raw files, then write a small set of durable wiki entries and, when applicable, semantic-layer sources that synthesize the WorkUnit's knowledge. Write final memory directly; do not write candidates.
Required Workflow
- Read the WorkUnit notes and rawFiles list. Page content lives in
page.md;metadata.jsonholds title, path, object type, data-source ids, last edited metadata, and properties. - For each assigned page, call
read_raw_file, orread_raw_spanfor oversized pages when the notes specify a span. - Search
wiki_searchfor existing pages that overlap the WorkUnit topics. Prefer updating an existing page over creating a duplicate. - Use
context_evidence_search,context_evidence_read, andcontext_evidence_neighborsto pull supporting chunks when indexed evidence is relevant. PasschunkIdanddocumentIdvalues verbatim as returned by the evidence tools. - Write durable business knowledge with
wiki_write. Aim for a small number of high-quality pages per WorkUnit or cluster. IncluderawPathswith the exact Notion raw files that support each page. - When the Notion content defines a reusable dataset, metric, segment, join rule, source-of-truth mapping, or table with explicit columns, load
sl_capture, discover existing sources first withsl_discoverorsl_read_source, then usesl_write_sourceorsl_edit_sourceonly for a confirmed mapped non-Notion target source. IncluderawPathswith the exact Notion raw files that support the SL action. If no mapped target exists, callemit_unmapped_fallbackand keep the content wiki-only. - For every deleted raw path in the Eviction Set, call
eviction_list, decide retention, thenemit_eviction_decision. Do this even when no wiki write is needed.
What To Capture
Capture durable, reusable company knowledge:
- metric definitions, KPI formulas, named business concepts, and reusable filters
- workflows, policies, ownership rules, approval conventions, and source-of-truth mappings
- data-source row pages that describe tables, columns, semantic models, dashboards, or business entities
- cross-system aliases connecting Notion terms to warehouse, dbt, Looker, Metabase, or MetricFlow names
- caveats, conflicts, supersession notes, and customer/product assumptions affecting future analysis
Skip noisy or transient content:
- meeting notes with no reusable rule
- task lists, project status updates, and time-bounded snapshots
- duplicate docs with no new fact
- database metadata pages when row pages contain the actual business content
- transient announcements and long page summaries
Quality
Prefer fewer, stronger entries. Every wiki entry must cite at least one Notion page or row using its path and last edited date when available. When evidence conflicts, write a conflict note inside the wiki page rather than choosing silently.
If a clustered WorkUnit includes several related pages, synthesize the shared rule or concept instead of writing one thin page per source. For oversized page spans, read only the assigned span unless the WorkUnit explicitly asks for neighboring context.
Search existing wiki pages for the same tables: or sl_refs: frontmatter and for source-of-truth aliases before creating a new page. If an existing page already documents the same warehouse object or business concept, update it instead of creating a differently named duplicate.
Citation Style
## Revenue Recognition
- Booked revenue excludes refunds and test accounts.
- Source: Notion - Company Handbook / Finance / Revenue Recognition, last edited 2026-04-12.
- Conflict note: An older Sales Ops page uses gross revenue before refunds; treat the Finance Handbook as current unless Finance says otherwise.
Semantic-Layer Rules
- Load
sl_capturebefore writing or editing SL sources. - Discover existing sources first with
sl_discover; read existing source YAML before editing. - Prefer overlays on manifest-backed sources over standalone SQL.
- If Notion describes a dashboard or metric but does not define executable logic, write a wiki page and attach
sl_refsonly after confirming the referenced source exists. - Notion
dataSourceCountcounts Notion databases/data sources only. It does not prove that a warehouse/dbt table has or lacks a mapped semantic-layer source. - Do not create SL sources under the Notion connection just because a page mentions a warehouse, dbt, Looker, or Metabase object. Use the mapped warehouse/source connection after discovery, or emit an unmapped fallback and write wiki-only.
- Distinguish fallback reasons precisely: if a non-Notion warehouse/dbt connection exists but
sl_discovercannot find the named table/source, useno_physical_table; reserveno_connection_mappingfor cases where there is no plausible non-Notion target connection at all. - If
sl_discoverresolves the table/source, do not callemit_unmapped_fallbackfor that table. Use the resolved source forsl_refs, overlay edits, or wiki-only documentation. - When calling
emit_unmapped_fallback, pass the table or source identifier astableRef(e.g.tableRef: "<schema>.<table>") - the tool generates the canonical detail string from the reason code andtableRef. Use the optionalclarificationfield only to add context that does not contradict the reason. Do not restate the reason inclarification.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Tools
Allowed: read_raw_file, read_raw_span, wiki_search, wiki_read, wiki_write, discover_data, entity_details, sql_execution, sl_discover, sl_read_source, sl_write_source, sl_edit_source, sl_validate, context_evidence_search, context_evidence_read, context_evidence_neighbors, emit_unmapped_fallback, eviction_list, emit_eviction_decision.
Not allowed: context_candidate_write, context_candidate_mark.
packages/cli/src/skills/sigma_ingest/SKILL.md
npx skills add Kaelio/ktx --skill sigma_ingest -g -y
SKILL.md
Frontmatter
{
"name": "sigma_ingest",
"callers": [
"memory_agent"
],
"description": "Extract durable ktx wiki knowledge from staged Sigma data model specs and workbook summaries. Load for WorkUnits with unitKey sigma-data-models or sigma-workbooks."
}
Sigma Ingest
Sigma ingest turns staged data model specs and workbook summaries into durable ktx wiki knowledge. The deterministic project() step has already written semantic-layer YAML for all warehouse-table data model elements before this skill runs — do not re-write those SL sources.
Work unit structure
Sigma produces at minimum two work units per ingest run:
sigma-data-modelsorsigma-data-models-NrawFiles:data-models/<id>.jsonfiles (one per data model in this batch)peerFileIndex:workbooks/<id>.jsonfiles +sigma-manifest.json+sigma-projection-config.json- When the workspace has more than 50 data models, split into batches:
sigma-data-models-0,sigma-data-models-1, … withdisplayLabellike"Sigma: data models (1/8)". When ≤50 data models, the unitKey is simplysigma-data-modelswith no suffix.
sigma-workbooksorsigma-workbooks-NrawFiles:workbooks/<id>.jsonfiles (one per workbook in this batch)peerFileIndex:data-models/<id>.jsonfiles +sigma-manifest.json+sigma-projection-config.json- When the workspace has more than 2000 workbooks, split into batches:
sigma-workbooks-0,sigma-workbooks-1, … withdisplayLabellike"Sigma: workbooks (1/4)". When ≤2000 workbooks, the unitKey is simplysigma-workbookswith no suffix.
sigma-manifest.json and sigma-projection-config.json are never in rawFiles. They live at the staged dir root and always appear in peerFileIndex.
Staged file shapes
data-models/<id>.json — one per data model (in rawFiles for data-model units):
{
"sigmaId": "abc-123",
"name": "Revenue Model",
"path": "Finance/Revenue Model",
"latestVersion": 3,
"updatedAt": "2026-01-15T00:00:00Z",
"isArchived": false,
"spec": {
"name": "Revenue Model",
"pages": [{
"id": "p1",
"name": "Main",
"elements": [{
"id": "elem1",
"kind": "table",
"name": "Opportunities",
"hidden": false,
"source": {
"kind": "warehouse-table",
"connectionId": "<sigma-internal-uuid>",
"path": ["DATABASE", "SCHEMA", "OPPORTUNITIES"]
},
"columns": [
{ "id": "c1", "name": "Deal Amount", "formula": "[OPPORTUNITIES/Amount]", "description": "Net contract value in USD" },
{ "id": "c2", "name": "Total ARR", "formula": "Sum([OPPORTUNITIES/ARR])", "description": "Annualised recurring revenue" }
]
}]
}]
}
}
source.kind discriminates:
warehouse-table— element maps directly to a warehouse table. HasconnectionIdandpath(array of path segments forming the fully-qualified table name).project()writes an SL source whenconnectionMappingscovers thisconnectionId.table— element is a derived view layered on top of another element; identified bysource.elementId. No warehouse path. Wiki-only.
workbooks/<id>.json — one per workbook, in rawFiles for workbook units (summary only; no spec endpoint exists):
{
"sigmaId": "wb-abc",
"name": "ARR Tracker",
"path": "Finance/Dashboards",
"latestVersion": 2,
"updatedAt": "2026-01-16T00:00:00Z",
"isArchived": false,
"workbookUrlId": "57a96EMo3G...",
"description": "Tracks ARR by segment and cohort for the finance team"
}
Peer files (available via peerFileIndex, not rawFiles):
sigma-manifest.json — fetch summary; use for provenance only.
sigma-projection-config.json — written by fetch(), contains two fields the skill must read:
connectionMappings:{sigmaInternalUuid: ktxWarehouseConnectionId}. Use the mapped warehouse connection ID forentity_detailswhen verifying warehouse identifiers found in data model specs.workbookFilter: the filter settings that were active when workbooks were last fetched:includeArchived(defaultfalse) — whenfalse, archived workbooks are not inworkbooks/;isArchived: truefiles will only appear when this wastrue.includeExplorations(defaultfalse) — whenfalse, exploration-type workbooks (unsaved analyses) are excluded; treat present workbooks as intentional, curated reports.updatedSince(optional ISO 8601 string) — when set, only workbooks updated on or after this date are staged; the set is a recent-changes slice, not the full workspace. Do not infer that absent workbooks were deleted.
sigma-manifest.json also reflects any active dataModelFilter. When dataModelFilter.updatedSince was set during fetch, dataModelCount reflects only matching models, not the full workspace. Do not infer that absent data models were deleted.
Read sigma-projection-config.json first and keep workbookFilter in scope while processing the WorkUnit.
Required workflow
- Read every
rawFilesentry for the WorkUnit. - Read
sigma-projection-config.jsonfrom the staged dir to getconnectionMappings. - For each data model file: extract business semantics from element names, column descriptions, and the domain context of the model. Skip hidden elements and hidden columns.
- For each workbook file: extract business domain knowledge from the name and description. When
workbookFilter.updatedSinceis set, treat the staged set as a recent-changes slice — absent workbooks were not deleted, they were simply outside the filter window. - Use
discover_databefore writing to find existing wiki pages on the same topic. - Write wiki candidates with
context_candidate_write. Do not callwiki_writedirectly from a Sigma WorkUnit; Stage 4 reconciliation promotes candidates. - Do not write or edit SL sources. The
project()step owns all SL output for Sigma.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- confirm the identifier resolves; inspect native types, FK/PK, and sampleValues. Use the warehouseconnectionIdfromconnectionMappingsinsigma-projection-config.json, not the Sigma connection ID. IfconnectionMappingshas no entry for the element'ssource.connectionId, skipentity_details— there is no mapped warehouse to verify against — and wrap any identifier references with[unverified - from <rawPath>].- For literal values from the source, such as status codes or plan tiers,
check whether they appear in
entity_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Data model elements
Warehouse-table elements (source.kind === "warehouse-table")
project() writes an SL source for a warehouse-table element only when the element's source.connectionId has an entry in connectionMappings. When no mapping exists, no SL source is written and the element is wiki-only.
To determine whether an SL source exists: check whether connectionMappings[element.source.connectionId] resolves. If it does, use sl_discover to find the source by its slugified name (<dataModelName>_<elementName>), then:
- Read the existing SL source with
sl_read_sourceto understand what columns and measures are captured. - Write a wiki candidate about the business domain if the element name, column descriptions, or data model description reveals durable knowledge not already in the wiki.
sl_refsin the wiki candidate should point to the already-written SL source name.
If connectionMappings has no entry for the element's source.connectionId, treat the element as wiki-only — do not attempt sl_discover or sl_read_source for it, as no source was written.
Joins within a data model
Joins are not projected in v1; joins: [] is always written by project(). Lookup() formulas may be described in wiki prose instead.
Non-warehouse elements (source.kind === "table")
These reference another element by elementId — they are derived views layered on top of a warehouse-table element. They have no warehouse path of their own. Do not attempt SL writes for these elements. They may produce wiki candidates if their column names or descriptions reveal business semantics not captured by the underlying warehouse-table element.
Workbooks
Workbooks have summary metadata only. There is no spec endpoint.
Extract business domain knowledge from:
name: the workbook's primary topic (e.g. "ARR Tracker" → ARR tracking concepts)description: business context and intended audiencepath: team or functional area (e.g.Finance/Dashboards)
Write wiki candidates when the name or description reveals a reusable business concept, metric definition, or domain convention. Write one candidate per distinct concept, not one per workbook.
Skip workbooks whose name or description contains no durable business semantics (e.g. "Untitled Workbook", "Test Dashboard").
Capture rules
Write wiki candidates for:
- Metric definitions mentioned in element names or column descriptions (e.g. "Net ARR", "Churned MRR")
- Domain conventions such as cohort definitions, segment taxonomies, or fiscal calendar rules
- Relationships between business entities revealed by data model joins
Skip:
- Visualization settings, layout, colors, chart types
- Owner names, folder paths, and version numbers as wiki narrative
- Hidden elements and hidden columns
- Data model names that are purely technical with no business meaning
- When
workbookFilter.includeExplorationsisfalse(the default), all staged workbooks are intentional reports — no extra exploration filter needed. When it istrue, workbooks without a description or with a generic auto-generated name are likely ephemeral explorations; skip those.
Usage signals
Sigma workbooks carry latestVersion but no usage counts. Treat a higher latestVersion as weak evidence of continued maintenance; do not include version numbers in wiki prose.
packages/cli/src/skills/sl_capture/SKILL.md
npx skills add Kaelio/ktx --skill sl_capture -g -y
SKILL.md
Frontmatter
{
"name": "sl_capture",
"callers": [
"memory_agent"
],
"description": "How to capture new reusable patterns into ktx's semantic layer - when a measure, segment, or join belongs in the catalog and how to write it generically so it stays small and useful over time. Loaded by the post-turn memory-agent only. The research agent does not write to the SL."
}
Semantic Layer - Capture
This skill covers when and how to capture new patterns into the semantic layer. For schema reference and query grammar, load the sl skill first.
When the current turn produces a reusable pattern (business metric, derived view, join pattern, computed dimension), capture it so future queries can reach for it instead of rediscovering it.
SQL dialect
The user-facing prompt includes a Warehouse: line under the SL Sources index
(e.g. Warehouse: BIGQUERY). All expr strings - measure expressions, segment
predicates, computed-column SQL - execute on that warehouse and must use its
syntax. Date arithmetic in particular varies by dialect:
- BigQuery:
transaction_date >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)(when the column isTIMESTAMP);event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)(whenDATE). - Postgres / Redshift:
transaction_date >= current_date - interval '90 days'. - Snowflake:
transaction_date >= dateadd(day, -90, current_timestamp()).
Match the column's manifest type (type: time → TIMESTAMP/DATETIME on the
warehouse) - comparing TIMESTAMP to a DATE-arithmetic result fails on
BigQuery. After every sl_edit_source/sl_write_source, the inline validator runs a
LIMIT 1 warehouse probe per measure and surfaces dialect mismatches; if
you see an error trailer, fix the expression and retry rather than leaving
the source for the post-squash gate to revert.
What's worth capturing
- Business metric aggregations (ARR, MRR, revenue, churn, retention, conversion, LTV, CAC).
- Derived calculations combining multiple signals (risk scores, health scores, composite KPIs).
- Multi-table join patterns producing a reusable analytical view.
- Computed categories or flags useful as reusable dimensions (
case when num_protocols >= 3 then 'power' else 'regular' end). - Missing joins between two sources that both exist but aren't connected in the join graph.
Skip:
- Simple
SELECT * LIMIT 10previews. - Trivial
COUNT(*)on one table with no business filtering. - One-off ad-hoc explorations unlikely to repeat.
- Equivalent measures that already exist (cite the existing one as
source.measure_name).
When in doubt, capture. Measures are easy to remove but impossible to recover from a lost conversation.
Generalization rules
The SL must stay small and general over time. Before adding a measure, decide whether it belongs as a generic pattern or a specific constant.
Prefer one generic measure with query-time filters over N hardcoded variants.
Anti-pattern:
- name: revenue_us_region
expr: sum(case when region = 'US' then amount end)
- name: revenue_eu_region
expr: sum(case when region = 'EU' then amount end)
Preferred:
- name: total_revenue
expr: sum(amount)
Callers filter region = 'US' at query time.
Bake constants in only when the filter has named business meaning that won't change (enterprise_arr for a contractually defined tier), cannot be expressed via the source's dimensions, or comes from a regulated/fixed list.
Time anchors and value lists belong in callers' filters, not in measure expressions or source SQL.
- Anti-pattern (date anchor inlined):
expr: count(distinct case when transaction_date >= '2026-04-12' then customer_id end)- the date will need editing every time the question shifts, and every reader has to discover it. - Anti-pattern (value list inlined in source SQL):
WHERE product_category_1 IN ('Testosterone', 'Weight Loss', …)- locks the source to today's catalog and blocks callers from broadening or narrowing. - Preferred: a generic measure (
count(distinct customer_id)) plus either a named segment that captures the meaning of the anchor (gh_new_products_since_launch) or a query-time filter. Callers compose; the source stays small. - A date is durable to bake in only when it represents a regulatory cutover, a contractually fixed boundary, or a one-time event that reshapes how the source itself is read.
If you create a segment whose expr matches a measure's filter, the measure MUST reference the segment via segments: [segment_name] rather than re-inlining the predicate. This is the canonical pattern even with a single measure - duplicating the predicate inline defeats the purpose of naming it.
Anti-pattern:
segments:
- name: engaged_subscriber
expr: "is_paid = true AND <date-window-90-days-on-transaction_date>"
measures:
- name: engaged_subscriber_count
expr: "count(distinct case when is_paid = true and transaction_date >= current_date - interval '90 day' then admin_user_id end)"
Preferred:
segments:
- name: engaged_subscriber
expr: "is_paid = true AND <date-window-90-days-on-transaction_date>"
measures:
- name: engaged_subscriber_count
expr: "count(distinct admin_user_id)"
segments: [engaged_subscriber]
Use computed dimensions for derived categories. A flag like is_power_user belongs on columns[] with expr, not inlined into every measure.
Extract repeated filter bundles into named segments. If the same predicate appears on multiple measures of the same source, lift it to a segments[] entry and have each measure reference it. One edit updates every measure that depends on it.
Never write a standalone file on a manifest-backed name. If sl_discover({ query: "<table-or-source-name>" }) finds an existing schema for that name, you MUST write an overlay. A standalone with sql: or table: on a manifest-backed name clobbers the inherited columns and joins; sl_write_source and sl_validate both reject this shape with a clear fix hint. Always run sl_discover before your first write on any existing name.
Overlay before/after examples:
# Wrong: patches an inherited manifest column through columns:
name: fct_orders
columns:
- name: status
descriptions:
user: "Order lifecycle status."
# Right: patch inherited columns with column_overrides:
name: fct_orders
column_overrides:
- name: status
descriptions:
user: "Order lifecycle status."
columns:
- name: is_large_order
type: boolean
expr: "amount > 1000"
Overlay YAML may include measures:, segments:, descriptions:, joins:, disable_joins:, exclude_columns:, column_overrides:, and computed-only columns: entries with expr and type. Do not include sql:, table:, grain:, or base-table columns:.
Prefer overlay decomposition over standalone SQL sources. Before reaching for source_type: sql, check whether the metric decomposes into measures on existing overlays (including cross-source derived measures). Use source_type: sql only when:
- The metric requires per-user/per-entity derivation that cannot be expressed as a single
expr(e.g.,EXISTSover a time-windowed subset), OR - The metric requires multi-step CTEs whose intermediate grain is not a column in any existing source.
When an sql source is unavoidable, note in its descriptions map which SL gap forced the choice so it can be retired once the primitive ships. It must target a name NOT in the manifest - pick a distinct one (e.g. mrr_waterfall_rollup, not fct_orders).
Slim standalone sources via inherits_columns_from
When a standalone SQL source filters or projects from a single manifest-backed base table (the common pattern for derived views like aav_consignments over MARTS.CONSIGNMENTS), set inherits_columns_from: to the base table's manifest key and list only column names in columns:. Compose-time enrichment fills type, descriptions, and role from the matching manifest column.
Discover the manifest key with sl_discover - pass the bare name (CONSIGNMENTS), the fully-qualified path (ANALYTICS.MARTS.CONSIGNMENTS), or any suffix; the tool resolves all forms and prints the canonical key in its output.
name: aav_consignments
descriptions:
user: AAV consignments - filtered view of MARTS.CONSIGNMENTS for the auto-auction-vaulting channel.
source_type: sql
sql: |
SELECT CONSIGNED_ITEM_ID, CASH_ADV_AMOUNT, ALT_VALUE_COMBINED, my_derived_flag
FROM MARTS.CONSIGNMENTS
WHERE IS_AUTO_AUCTION_VAULTING_SUBMISSION = TRUE
AND IS_CARD_SHOW_SUBMISSION = FALSE
AND CONSIGNMENT_CANCELED_FLAG = FALSE
inherits_columns_from: CONSIGNMENTS
grain: [CONSIGNED_ITEM_ID]
columns:
- { name: CONSIGNED_ITEM_ID } # type/descriptions inherited from manifest
- { name: CASH_ADV_AMOUNT }
- { name: ALT_VALUE_COMBINED }
- { name: my_derived_flag, type: boolean, expr: "CASH_ADV_AMOUNT > 0", descriptions: { user: "Computed locally - has any cash advance." } }
measures:
- name: total_cash_advance
expr: sum(CASH_ADV_AMOUNT)
Rules:
- Inheritance fills only blank fields. If you set a
descriptionlocally, it wins - useful when the base description is misleading in the filtered view. - A column not in the manifest (a derived/aliased column, or one from a different table in a
JOIN) needs its owntypeanddescriptiondeclared. - If
inherits_columns_fromdoesn't resolve, the source still loads, but every column without a type triggers a validator error on the warehouse probe -sl_discoverfirst to confirm the key. - Don't use
inherits_columns_fromfor sources backed bytable:(those should be overlays - see the rule against shadowing the manifest above).
Refinement - replace, don't append
When the user corrects a prior answer, the existing measure is wrong by the user's own standard. Replace it, don't add a parallel measure.
Signals that the current turn is a refinement:
- "no, I meant...", "actually use X", "exclude Y", "wait, by X I mean Z".
- Pushback on a prior result ("that's wrong because...", "this should be higher").
- Redefinition of a term used in an existing measure.
Distinguishing question: would the prior measure still be correct for someone else asking the prior question? If no → replace. If yes → add.
Edit SL vs document in wiki
If the user explicitly names an SL artifact and asks to change it, the primary action is always an SL tool call. Examples:
- "edit the source", "edit the YAML", "edit
fct_intakes.yaml" →sl_edit_sourceorsl_write_source. - "refine the measure", "change the filter on
active_users", "fix the expr", "addis_test = false" →sl_edit_sourceon the source that owns the measure. - "don't create a new one, update the existing" →
sl_edit_source(neversl_write_sourcewith a new source name; neverwiki_writeas the only action).
A wiki update may ALSO make sense in the same turn (owner note, lineage, caveat), but it is never a substitute for editing the YAML when the user's request is about changing the measure/source definition itself.
Wiki-only is correct when the user is documenting about the measure (definition in business terms, owner, policy, glossary, examples of when to use it) without changing its SQL expression or filters.
Before sl_write_source, call entity_details on the target table to confirm column names and types match the YAML being written.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Tool sequence
sl_discover- see what source files exist.sl_discover({ query: "<table-or-source-name>" })- REQUIRED before the first write on any name. Shows columns/joins/grain from the manifest. If the call returns a schema, you MUST write an overlay, not a standalone. Skipping this is the #1 cause of accidentally shadowing the manifest.sl_read_source({ connectionId, sourceName })- read the raw YAML before editing.- For modifications:
sl_edit_source({ connectionId, sourceName, yaml_edits: [{ oldText, newText, reason }] })with exact-string replacements.oldTextmust match exactly and be unique in the file. - For new sources or full rewrites:
sl_write_source({ connectionId, sourceName, source })with the full structured source definition. - For join discovery: use
sql_execution({connectionId: "warehouse", sql: "SELECT count(*) FROM public.orders o JOIN public.customers c ON c.id = o.customer_id LIMIT 20"})with the target warehouse connection id and dialect-correct table names to verify the join key exists in both tables and assess cardinality before declaring the join. - Cross-reference knowledge: author the edge once on the wiki side via
sl_refs: [source_name]in the page's front-matter. The reverse edge (wiki pages that cite an SL source) is derived automatically by the reconciler - do not add aknowledge_refs:field to SL YAMLs. sl_validate- run after writing or editing to surface schema issues, duplicate measure names, and cross-source validation errors. Read-only; the writes are already committed (the squash-at-end flow will collapse them into one commit).
Editing patterns
sl_edit_sourceis the workhorse for additive changes: add a measure, add a join, tweak a description, replace a filter. Cheap, targeted, preserves the rest of the file.sl_write_sourceis for brand-new sources or when the entire file needs restructuring. It overwrites the file completely.- Do NOT modify existing measures or their descriptions unless the current turn explicitly corrects them.
- During bundle/external ingest, include
rawPathson everysl_write_source/sl_edit_sourcecall with only the raw files that directly support the SL action.
Worked example - additive overlay
Conversation:
- User: "What was the average order value last quarter?"
- Assistant fell back to SQL:
SELECT AVG(amount) FROM orders WHERE order_date >= ...
Existing index: orders [measures=0, joins=0] - candidate for enrichment.
sl_discover()
→ orders.yaml does not exist yet
sl_discover({ query: "orders" })
→ see grain, columns, no current overlay
sl_write_source({
connectionId: "warehouse",
sourceName: "orders",
source: {
name: "orders",
measures: [{
name: "avg_order_value",
expr: "avg(amount)",
description: "Mean order transaction amount - filter by product_category at query time"
}]
}
})
sl_validate({ connectionId: "warehouse" })
→ clean
The overlay only contains name and measures - no columns, grain, or table. Those are inherited from the manifest.
Worked example - refinement (replace)
Prior turn:
- [user] "How many active users do we have per region?"
- [assistant] "… used
count(*) filter: last_login_at > now() - interval '30 days'"
Current user: "Wait, by 'active' I mean users who have placed an order in the last 30 days, not just logged in."
The existing users.active_count measure is wrong by the new definition.
sl_read_source({ connectionId: "warehouse", sourceName: "users" })
→ see the wrong measure
sl_edit_source({
connectionId: "warehouse",
sourceName: "users",
yaml_edits: [{
oldText: " - name: active_count\n expr: \"count(*)\"\n filter: \"last_login_at > now() - interval '30 days'\"\n description: Users who logged in within the last 30 days",
newText: " - name: active_count\n expr: \"count(distinct case when last_order_at > now() - interval '30 days' then user_id end)\"\n description: Users with at least one order in the last 30 days"
}]
})
sl_validate({ connectionId: "warehouse" })
If you only added a new measure, the old incorrect active_count would stay and future queries would keep answering the wrong question.
Worked example - new join
Prior turn: user asked to correlate LTV with protocol count; assistant joined fct_orders with fct_mau_multiprotocol on admin_user_id in raw SQL.
sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
→ no joins section yet
sql_execution({
connectionId: "warehouse",
sql: "SELECT COUNT(*), COUNT(DISTINCT a.admin_user_id) FROM public.fct_orders a JOIN public.fct_mau_multiprotocol b ON a.admin_user_id = b.admin_user_id LIMIT 1"
})
→ confirms cardinality (many orders per MAU row = many_to_one)
sl_edit_source({
connectionId: "warehouse",
sourceName: "fct_orders",
yaml_edits: [{
oldText: "measures:",
newText: "joins:\n - to: fct_mau_multiprotocol\n on: admin_user_id = fct_mau_multiprotocol.admin_user_id\n relationship: many_to_one\nmeasures:"
}]
})
sl_validate({ connectionId: "warehouse" })
Always verify joins with sql_execution before adding them.
Rules recap
- Read existing sources before editing (
sl_read_sourceorsl_discover). - Prefer overlays over standalone sources on manifest-backed tables.
- Prefer generic measures + query-time filters over per-value variants.
- Time anchors and value lists belong in callers' filters, not in measure expressions.
- A measure whose filter matches a segment MUST reference the segment via
segments: [name]. - Extract repeated predicates into named segments.
- Use computed dimensions for derived categories.
- When the user corrects a prior answer, replace - don't append.
- Always run
sl_validateafter writing to surface issues. - If nothing is worth capturing, respond without calling any SL write tool.
packages/cli/src/skills/sl/SKILL.md
npx skills add Kaelio/ktx --skill sl -g -y
SKILL.md
Frontmatter
{
"name": "sl",
"description": "ktx's semantic layer - a structured catalog of sources (tables\/views), measures, joins, and segments expressed as YAML. Covers the schema and how to query it via `sl_query`. Use when the task involves querying pre-defined metrics (ARR, churn, retention, LTV, MAU) or reading SL source YAML to understand the catalog. Capture is handled by the `sl_capture` skill (memory-agent only)."
}
Semantic Layer
ktx's semantic layer (SL) is a structured catalog. Each source represents a table, a SQL view, or an overlay that enriches a manifest-backed table with measures, computed columns, joins, and named segments. The catalog is the single source of truth for reusable business metrics.
This skill covers two parts:
- Part 1 - Schema reference (what an SL source looks like).
- Part 2 - Querying via
sl_query.
Capture (when and how to add new patterns to the SL) is a separate concern handled by the memory-agent - see the sl_capture skill if you are running in capture mode. The research agent reads and queries the SL via the tools described here; it does not write to it.
For capture-time identifier verification, load sl_capture. Synthesis writer
skills must verify warehouse identifiers with discover_data,
entity_details, and sql_execution before emitting table or column names.
Part 1 - Schema reference
An SL source is a YAML file under semantic-layer/<connectionId>/. The file's name: field is the source's identity — it mirrors the warehouse identifier verbatim (e.g. Snowflake's uppercase SIGNED_UP); the filename is only a derived label. Always address sources by name through the sl_* tools, never by file path. There are three flavors:
Overlay sources
Enrich a manifest-backed table with measures, computed columns, joins, and segments. No table or sql field. The base table's columns and grain are inherited from the manifest.
name: fct_orders # must match an existing manifest table
descriptions:
user: "Overlay adding business measures to the orders fact table."
measures:
- name: total_revenue
expr: sum(amount)
description: Total order revenue - filter by status or region at query time
columns: # computed dimensions only
- name: is_large_order
type: boolean
expr: "amount > 1000"
column_overrides: # metadata patches for inherited columns
- name: status
descriptions:
user: "Order lifecycle status."
segments:
- name: paid_non_refunded
expr: "is_paid = true AND is_refunded = false"
joins:
- to: customers
on: "customer_id = customers.id"
relationship: many_to_one
Rules:
- Do not repeat base-table columns, grain,
table, orsource_typein an overlay - those are inherited. - Overlay columns MUST be computed (
expr+type). - Use
column_overridesto add descriptions or metadata to inherited manifest columns. Do not puttypeorexprincolumn_overrides. exclude_columnshides specific manifest columns;disable_joinssuppresses specific auto-detected joins.
Standalone table sources
Self-contained; own their schema. Has source_type: table and table:.
name: account_health_scores
source_type: table
table: "analytics.account_health_scores"
grain: [account_id, snapshot_date]
columns:
- name: account_id
type: string
- name: snapshot_date
type: time
role: time
- name: health_score
type: number
measures:
- name: avg_health_score
expr: avg(health_score)
Standalone SQL sources
Self-contained; schema derived from a SQL query. Has source_type: sql and sql:.
name: monthly_cancellations
source_type: sql
sql: |
SELECT
date_trunc('month', cancelled_at) AS month,
customer_id,
plan_name,
mrr_amount
FROM subscriptions
WHERE status = 'cancelled'
grain: [customer_id, month]
columns:
- name: month
type: time
role: time
- name: customer_id
type: string
- name: plan_name
type: string
- name: mrr_amount
type: number
measures:
- name: cancellation_count
expr: count(*)
An SQL source is a one-shot answer: the aggregation is frozen, callers cannot re-group or re-filter by columns the SQL has collapsed, and the source is disconnected from the join graph. Prefer overlays + measures over SQL sources when possible - the sl_capture skill covers when SQL is justified.
Columns
Every standalone column requires name and type. Overlays have computed columns in columns: and manifest column metadata patches in column_overrides:.
type: one ofstring,number,boolean,time. Map LookMLdate/datetime/timestamp→time. Map LookMLyesno→boolean.role(optional):timeenables time-granularity queries (month, week, day).defaultis the implicit fallback.visibility(optional):public,internal, orhidden.expr(optional for standalone, required for overlay columns): SQL expression that computes the value. Expanded by sqlglot before generating SQL, so you can reference other columns on the same source.
Grain
grain: [col_a, col_b] - the set of columns that uniquely identify one row. The query engine uses grain to prevent fanout in joins. Overlays inherit grain from the manifest unless they override.
Joins
joins:
- to: customers # target source name
on: "customer_id = customers.id" # local_col = TARGET.target_col
relationship: many_to_one # or one_to_many, one_to_one
alias: primary_customer # optional - lets you join the same target twice
onformat:local_col = TARGET.target_col. Always qualify the right side with the target source name.relationshipis the cardinality from this source to the target. Most joins aremany_to_one(FK → PK on the parent).
Measures
measures:
- name: total_arr
expr: sum(arr_amount)
description: Sum of ARR - filter by plan_name at query time
filter: "is_active = true"
segments: [paid_non_refunded]
name(required, snake_case).expr(required): any valid SQL aggregate -sum(x),count(*),count(distinct user_id),avg(score).description(required on capture): what the measure computes and how to use it.filter(optional): SQL predicate applied as a WHERE clause specific to this measure.segments(optional): names of segments defined on the same source. The engine AND-composes each segment'sexprinto this measure's effective filter.
Use safe_divide(num, den) for ratio measures to avoid division by zero.
Segments
segments:
- name: paid_non_refunded
expr: "is_paid = true AND is_refunded = false"
description: Orders that were paid and not refunded
Named, reusable boolean predicates scoped to one source. Reference by bare name in a measure's segments: [], or by dotted form source.segment_name in an sl_query. Segments are predicates only - they are NOT selectable as dimensions. If you need to group by the predicate, add a columns[] entry instead.
Cross-references with the wiki
The reverse edge (wiki pages that cite this source) is derived automatically from each wiki's sl_refs: - you don't emit anything on the SL side. Author the edge once on the wiki via sl_refs:; the post-write reconciler populates the knowledge↔SL index.
Part 2 - Querying via sl_query
The sl_query tool generates correct SQL from a structured query. It handles joins, fanout prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources.
When to prefer sl_query over raw SQL
- A pre-defined measure already exists (
source.measure_nameappears in the catalog). - The question combines fields from multiple sources - the engine resolves the join path automatically.
- The question asks for a standard metric (revenue, ARR, churn, retention, LTV, conversion, MAU, etc.) - even if no pre-defined measure exists, a runtime aggregation over a catalog column is usually correct.
Use raw SQL (sql_execution) only when:
- The computation requires multi-step CTEs whose intermediate grain is not a column in any source.
- The question explicitly asks for a one-off exploration that will never be asked again.
Input shape
{
"connectionId": "uuid-of-the-connection",
"measures": ["orders.total_revenue", "sum(orders.amount)"],
"dimensions": ["customers.segment", { "field": "orders.created_at", "granularity": "month" }],
"filters": ["orders.status != 'cancelled'", "orders.total_revenue > 10000"],
"segments": ["orders.paid_non_refunded"],
"order_by": [{ "field": "orders.created_at", "direction": "desc" }],
"limit": 1000
}
measures: mix pre-defined refs (source.measure) and runtime aggregations (sum(source.column)).dimensions: column refs or{ field, granularity }objects for time grains (day,week,month,quarter,year).filters: free-form SQL predicates. The engine auto-classifies each as WHERE or HAVING based on whether it references an aggregated measure.segments: dottedsource.segment_name. Each segment is AND-ed into the effective filter of every measure whose base source matches. Segments never become a global WHERE - usefiltersfor cross-source predicates.order_by: string or{ field, direction }. Direction defaults toasc.limit: integer row cap.
Join resolution
You don't specify a base table. The engine infers the set of sources needed from the fields you reference and resolves the shortest join path through the catalog's declared joins. If no path exists between two sources, the query fails with a path-not-found error - check discover_data or sl_discover to see which sources are connected.
Worked examples
Cross-source query - engine resolves account_health_scores → accounts ← opportunities automatically:
{
"measures": ["account_health_scores.avg_health_score"],
"dimensions": ["opportunities.stage"],
"filters": ["opportunities.stage != 'Closed Won'"]
}
Monthly ARR trend with a segment:
{
"measures": ["subscriptions.arr"],
"dimensions": [{ "field": "subscriptions.month", "granularity": "month" }],
"segments": ["subscriptions.paid_non_refunded"],
"order_by": [{ "field": "subscriptions.month", "direction": "asc" }]
}
Multi-source with runtime aggregation:
{
"measures": ["sum(orders.amount)", "count(support_tickets.ticket_id)"],
"dimensions": ["customers.segment"]
}
packages/cli/src/skills/wiki_capture/SKILL.md
npx skills add Kaelio/ktx --skill wiki_capture -g -y
SKILL.md
Frontmatter
{
"name": "wiki_capture",
"callers": [
"memory_agent"
],
"description": "ktx's knowledge base - wiki pages for durable, reusable business knowledge. Covers capture workflow for user preferences, metric definitions, organizational conventions, and cross-references between wiki pages and semantic-layer sources. Loaded by the post-turn memory-agent only. The research agent reads wiki via `wiki_read`\/`wiki_search` but does not write it."
}
Wiki Capture
Role
The knowledge base stores durable, reusable business knowledge for an analytics assistant. Each page is a self-contained rule, definition, or convention that answers "how should this concept be handled in this organization?" - written once and reused across chats.
Scope selection is handled by the runtime:
- When user-scoped knowledge is enabled AND the caller is a chat turn, writes go to the user's personal scope.
- When the caller is an admin-driven ingest (
sourceType: 'external_ingest'), writes go to the global scope. - When user-scoped knowledge is disabled, all writes go to the global scope.
The wiki_write tool picks the right scope based on the session. Capture logic does not need to choose - focus on whether the content is worth capturing at all.
What to capture
Capture when the user or the ingested document expresses:
- A metric definition ("revenue means booked revenue after refunds").
- A filter or convention that should always apply ("exclude test accounts when reporting ARR").
- A mapping or alias ("mood_stress_sleep = Oxytocin protocol").
- A domain rule that is not visible from column names alone ("status = 'T' means terminated, not 'terminated'").
- A link or external system convention ("medplum_patient_id is the primary key in the EMR at https://emr.example/patients/{id}").
Do NOT capture:
- One-off requests ("answer under 100 words").
- Temporary instructions scoped to the current chat.
- Ad-hoc formatting preferences.
- Information already present in the semantic layer (column names, join paths, measure formulas - those belong in SL).
- Query results, snapshots, or time-bounded benchmark tables. Numbers go stale; pasting "Oct 2025: 25%, Nov 2025: 19.9%, …" creates misinformation as soon as new data lands. Reference the SL source by name (
sl_refs) and let future query tools pull live data - the wiki captures the rule (definition, exclusion, segmentation), the SL source captures the measure, and query execution captures the current values. - Interpretive narrative tied to a specific snapshot ("M1 retention degraded sharply from Dec 2025"). The observation is anchored to data that will move; the actionable convention (e.g., "always exclude in-progress cohorts") may be worth capturing on its own, but the snapshot-specific commentary is not.
If nothing is worth capturing, respond without calling any tool.
Workflow
- Read the wiki index (provided in the prompt) and decide whether the turn introduces durable knowledge.
- Before writing, search for related content so cross-references are accurate:
discover_datafirst when a page relates to data or SL concepts - find existing wiki pages, SL sources, and raw warehouse schema together.wiki_searchwith the topic - find related wiki pages to populaterefs.sl_discoverwith the concept - if the page defines a metric (revenue, churn, retention, LTV, ARR, MRR, CAC, attribution, etc.), find matching SL sources or measures to populatesl_refs. If no matches, passsl_refs: []so future readers know you checked.
- If updating an existing page,
wiki_readit first. Use the returnedstructured.contentor markdown body as the exact stored text for targeted replacements; current tags, refs, and sl_refs are returned in structured metadata. wiki_writeto create or update. Prefer merging into an existing page over creating a new one.wiki_removeonly when a page is truly obsolete - not to replace stale content (update it instead).
For bundle/external ingest, include rawPaths on every wiki_write/wiki_remove call with only the raw files that directly support that wiki action. This keeps ingest provenance tied to the actual source file, not every file in the WorkUnit.
Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
discover_data({query: "<topic>"})- 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: "<identifier>"}]})- 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_detailssampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run asql_executionprobe with the same warehouse connection id:sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}). - If the candidate identifier still does not resolve, do one of:
- Use
sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional. - Wrap the identifier in
[unverified - from <rawPath>]in the wiki body, citing the exact raw path that mentioned it. - When recording
emit_unmapped_fallbackwithno_physical_table, include the failing probe error inclarification.
- Use
- Never copy
<schema>.<table>placeholder strings from these instructions into output.
Keys, summaries, and content
- Keys are short kebab-case topic identifiers:
leads-source-filter,revenue-definition,churn-calculation. No namespacing, no prefixes. - Summary is a one-line hook (≤200 chars) shown in the index.
- Content is concise markdown - actionable rules, not prose.
## [Topic Title]
- Rule or preference statement
- Another rule if applicable
Prefer fewer, richer pages over many thin ones. Each page covers one coherent topic thoroughly. If the new information relates to an existing page, update that page instead of fragmenting the knowledge.
Tags, refs, sl_refs
The wiki_write tool accepts three array fields that go into the page frontmatter:
tags: 1–3 short lowercase topic tags (["finance"],["data-quality"]). Callwiki_list_tagsfirst to reuse existing tags for consistency.refs: keys of related wiki pages. Add when the new page materially depends on concepts from another (e.g., a churn definition that uses the paid-orders filter from a revenue definition). Don't add refs just because pages share a topic area.sl_refs: names of SL sources or measures the page relates to. Format:"source_name"or"source_name.measure_name". Discover viasl_discover→ inspect withsl_read_source→ include the confirmed matches.
Wiki page keys must be flat slugs. Use large-contract-requesters, not
historic-sql/large-contract-requesters. Use tags, source, and content
headings for grouping.
Replace semantics
All three fields use REPLACE semantics on update:
- Omit the field → existing value is kept.
- Pass
[]→ field is cleared. - Pass
[values]→ replaces existing with exactly those values (no merging).
Connection scoping
A project may have several databases whose schemas reuse the same concept names
(two warehouses each with orders, customers, …). The connections
frontmatter field keeps database-specific pages from polluting searches about
other databases.
- The
wiki_writetool accepts aconnectionsfield (list of connection ids, same REPLACE semantics astags). Absent or empty ⇒ the page is unscoped and applies to every connection. - When this ingest/turn is scoped to a connection (its id appears in the prompt
context — e.g.
connectionId: warehousein the SL Sources header or the<context>block), setconnections: [<that id>]on pages whose content is specific to that database ("in this warehouseuser_idis the device id, not the account id"). Pair this with a connection-distinctive key so two databases' same-concept pages can coexist:orders_sales_db, notorders. - Leave
connectionsempty for clearly org-wide knowledge ("fiscal year starts in February") so it stays visible everywhere. Do not scope a page to a connection just because the turn happened to be connection-scoped. - Keys are still a flat, global namespace;
connectionsdoes not namespace them. A connection-scoped write whose key already belongs to a page scoped to a different connection is rejected to prevent silently overwriting it — pick a connection-distinctive key instead.
Editing existing pages
Two modes:
- Full content - pass
contentto rewrite the whole page. Use when the page structure needs to change. - Targeted edits - pass
replacements: [{ oldText, newText }]to apply exact-string replacements. Use for small updates; preserves the rest of the page.
When editing, read the page first so the edit matches exact whitespace and indentation.
Overriding an organization rule
Organization (GLOBAL) pages are read-only from a user's personal-scope session. To override a global rule for a single user, write a personal page with the same key. At read time the USER page wins.
Worked example - capturing a metric with cross-references
User says: "Going forward, the official refund rate is total refunded amount divided by total gross transaction amount."
wiki_list_tags()
→ existing tags include "finance"
wiki_search({ query: "refund revenue paid orders" })
→ returns `revenue-definition` (related - defines paid-orders filter)
sl_discover({ query: "refund rate" })
→ returns fct_orders (score 0.08), fct_gaap_revenue (0.06)
sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
→ confirms amount_refunded_dollars and transaction_amount_dollars exist
wiki_write({
key: "refund-rate-definition",
summary: "Refund rate = refunded amount / gross transaction amount",
content: "## Refund Rate\n- Definition: sum(amount_refunded_dollars) / sum(transaction_amount_dollars)\n- Source of truth: fct_orders\n- Related: see revenue-definition for paid-orders filter.",
tags: ["finance"],
refs: ["revenue-definition"],
sl_refs: ["fct_orders.refund_rate_pct", "fct_orders"]
})
Search-then-write order matters. Cross-references are part of the page's identity, not an afterthought.
Rules
- Read existing pages before updating them.
- Prefer merging into an existing page over creating a new one.
- Prefer fewer, richer pages over many thin ones.
- Write content as clear, actionable rules - not narrative prose.
- Discover cross-references via search before writing, not after.
- If nothing is worth capturing, respond without calling any tool.
skills/ktx/SKILL.md
npx skills add Kaelio/ktx --skill ktx -g -y
SKILL.md
Frontmatter
{
"name": "ktx",
"description": "Installs and configures ktx, the open-source context layer for data agents — runs ktx setup non-interactively with hidden CLI flags, configures database connections and embeddings, installs agent integration, and verifies readiness. Use when the user asks an agent to add ktx to a project, connect data sources, install agent rules, ingest schema, or troubleshoot a local ktx install."
}
ktx
Install and configure ktx, the open-source context layer for data agents. Use this skill when a user wants an agent to add ktx to a project, connect data sources, build initial context, install agent integration, or troubleshoot a local ktx setup.
Operating rules
- Act autonomously when the user asks you to install or configure ktx.
The non-interactive scripted flow below is the canonical path — bare
ktx setupis interactive (clack prompts) and an agent cannot drive it. - Setup's non-interactive flags are intentionally hidden from
--help. Use the flags listed below; verify uncommon flags against the docs athttps://docs.kaelio.com/ktx/or this skill — not against--helpoutput. - Ask only for values you cannot infer: project directory, connection targets, credentials, account identifiers, and source selections.
- Prefer
file:/abs/pathsecret refs overenv:VAR_NAME.env:refs are re-resolved against the process environment on everyktxrun, so a var exported only in the setup shell is gone whenktx ingestorktx mcp startruns later — the secret silently resolves to empty and the connection fails.file:refs read from disk and survive across shells. The same caveat applies to--*-api-key-envflags: the named var must be present in every shell that runsktx, including thektx mcpdaemon's environment. - A literal database URL is safe to pass —
ktx setupauto-externalizes it into.ktx/secrets/<id>-urland rewritesktx.yamlto afile:ref (see workflow step 2). Source credential refs are not auto-externalized: write the secret to a file under.ktx/secrets/(chmod 600) and pass afile:ref. Never ask the user to paste a secret when afile:orenv:ref works. - Do not commit
.ktx/secrets/*. - Print each command you run and its result.
- Setup and ingest can run for many minutes (LLM-heavy source ingests take the longest), and from the outside a slow step looks identical to a stuck one. Don't go silent: say what's about to run and that it may take a while, then post brief progress/liveness updates while it runs (see step 4) so the user never has to wonder whether it stalled — otherwise they may kill it mid-run.
- If a command fails, identify the cause and change something before retrying.
Gather inputs once
Before invoking ktx setup, collect in one round:
- Project directory (default: current working directory).
- LLM backend and key strategy. In
--no-inputmode the CLI defaults toanthropicand requires an API key. When the user is inside Claude Code, pass--llm-backend claude-codeexplicitly; otherwise pass--llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY. - Embedding backend (
sentence-transformersis the local default and needs no key; useopenaionly if the user already has a key, then pass--embedding-api-key-env OPENAI_API_KEY). - Database: driver, connection id, URL (or
env:/file:ref), and one or more schemas. - Optional context sources (dbt, Metabase, Looker, LookML, MetricFlow,
Notion). Add each one with a follow-up
ktx setup --source …run (see Add context sources); use--skip-sourcesonly when the user has none.
Do not discover these inputs across multiple setup runs.
Install workflow
-
Detect the install path. If the working directory contains
packages/cli/dist/bin.jsorpnpm-workspace.yamlreferencing@kaelio/ktxyou are inside the ktx monorepo — build and link the local CLI withpnpmand do not runnpm install -g. Otherwise:node --version # require >= 22; stop and ask the user if older ktx --version || npm install -g @kaelio/ktx -
Run scripted setup (canonical path):
ktx setup --no-input --yes \ --project-dir <path> \ --llm-backend claude-code \ --embedding-backend sentence-transformers \ --database <driver> --database-connection-id <id> \ --database-url '<raw-url | file:/abs/path>' \ --database-schema <schema> \ --skip-sources \ --skip-agents--database-schemais required for scope-bearing drivers (Postgres, MySQL, ClickHouse, SQL Server, BigQuery, Snowflake) in--no-input: setup fails fast without it unless the connection already has scope inktx.yaml. SQLite needs no scope.- Configure one new database connection per setup invocation. For multiple connections, rerun setup once per connection.
- Pasting a literal
--database-urlis safe: the CLI relocates the URL into.ktx/secrets/<connection-id>-urland rewritesktx.yamlto afile:ref automatically. ktx setupruns agent integration as its last step. In--no-inputmode with neither--targetnor--skip-agents, that step has no input, printsRun in a TTY, or pass --target <target>., and the command exits non-zero even though every database/LLM/embedding step succeeded. Pass--skip-agentsto defer agents to step 5 (as above), or--target <agent>to install them inline and exit 0. Judge data-layer success fromktx status, not from this exit code.
-
Resumability and
--skip-*. Re-runningktx setupagainst an existing project resumes its config. Use--skip-llm,--skip-databases,--skip-sources, or--skip-embeddingsto leave a slice unconfigured but let the rest complete instead of aborting on the first failure. When resuming an existing project to change one slice (e.g. only LLM), still pass the database flags from the previous run — setup validates current flags, not persistedktx.yamlstate. -
Build context if setup did not already complete one:
ktx ingest <connection-id> --no-inputktx ingestalways builds enriched context and requires a configured model and embeddings (set during setup); a database connection without them fails with an enrichment-readiness error. Note:ktx ingestrejects--yestogether with--no-input(Choose only one runtime install mode);ktx setupaccepts both. Use--no-inputonly for ingest.Ingest one connection at a time. It can run for many minutes with no stdout until it exits (LLM-heavy sources like Metabase are the slowest), so don't assume it hung, and don't pipe it through
tail/head— that buffers all output to the end, so run it raw. Tell the user up front that the step is slow, then keep them posted instead of blocking silently: run the ingest in the background and poll for liveness every minute or so, reporting a one-line update each time (which connection, roughly how long it's been running, and that.ktxfiles are still changing) so a long run never looks stuck:find <path>/.ktx/worktrees <path>/.ktx/ingest-transcripts -type f -mmin -3On success, the
Ingest finishedsummary table showsdonein theSource ingestandMemory updatecolumns with noFailed sources:section. -
Install agent integration:
ktx setup --agents --target <claude-code|claude-desktop|codex|cursor|opencode|universal> ktx mcp start --project-dir <path>Agent integration is not usable until
ktx mcp startis running. The--agentsstep prints this requirement asRequired before using agents. -
Fall back to bare
ktx setuponly when a human is at the keyboard — it uses interactive prompts an agent cannot answer.
Add context sources
Context sources (dbt, Metabase, Looker, LookML, MetricFlow, Notion) are added
one at a time — --source is not repeatable, so run ktx setup once per
source. Source setup is resumable against an existing project: pass
--skip-databases --skip-llm --skip-embeddings --skip-agents so only the source
is configured (the trailing agent step otherwise fails the run — see install
step 2). Map Metabase, Looker, and LookML to an existing database connection
with --source-warehouse-connection-id <db-connection-id> (required for those).
dbt ignores --source-warehouse-connection-id — it maps to the warehouse by
table name — so omit it for dbt. Use file:/abs/path refs for keys and tokens
(see the secrets rule above); env: refs must be exported in every later ktx
shell.
# dbt — pick exactly one of --source-path (local) or --source-git-url (remote).
# No --source-warehouse-connection-id: dbt maps to the warehouse by table name.
ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \
--source dbt --source-connection-id <id> \
--source-git-url <url> --source-branch <branch>
# Metabase
ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \
--source metabase --source-connection-id <id> \
--source-url <url> --source-api-key-ref file:/abs/path/metabase-api-key \
--source-warehouse-connection-id <db-connection-id> \
--metabase-database-id <metabase-db-id>
# Notion
ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \
--source notion --source-connection-id <id> \
--source-auth-token-ref file:/abs/path/notion-token \
--notion-crawl-mode selected_roots --notion-root-page-id <page-id>
Notes:
--metabase-database-idis the numeric id of the warehouse inside Metabase (not the ktx connection id). Discover it from the Metabase API (GET /api/database) or UI if the user doesn't know it.--notion-crawl-mode selected_rootsrequires at least one--notion-root-page-id(repeatable); useall_accessibleto crawl everything the token can see.- After adding sources, ingest each new connection so its context is queryable:
ktx ingest <source-connection-id> --no-input.
Files to inspect
ktx.yaml: project configuration..ktx/secrets/*: local secret files. Never commit them.semantic-layer/<connection-id>/*.yaml: semantic sources for SQL compilation.wiki/**/*.md: project context pages for agents..claude/skills/ktx/,.agents/skills/ktx/,.cursor/rules/ktx.mdc, and.opencode/commands/ktx.md: generated agent integration files.
Verification
After setup, run:
ktx connection test <connection-id>
ktx status --json --no-input
ktx sl --output plain # lists compiled semantic sources; `ktx sl` has no --no-input
Judge readiness from ktx status --json fields, not the exit code.
ktx status exits 1 whenever the LLM is none (verdict: "blocked"), even
when embeddings and every database connection are healthy. Treat success as:
verdict: "ready"at the top of the JSON, and- every
connections[].status === "ok"(other levels:warn,fail,skipped), and - every
ktx connection test <id>exited 0, and - for each ingested source,
localStats.semanticLayer[].sourceCount > 0andlocalStats.wikiPages[].count > 0— these confirm the source actually produced context. Do not rely onlocalStats.ingest.perConnectionto confirm source ingests: it reflects only completed warehouse ingest reports and under-reports (often lists just the warehouse connection).
If the LLM is intentionally left unconfigured, verdict is blocked and the
exit is non-zero by design — that is still a usable context layer, so report it
as "ready, LLM optional" and judge the data layer by the connection and
localStats fields above rather than retrying setup.
Troubleshooting
For known failure signatures (invalid ELF header,
Native CLI binary for <plat> not found, Missing Anthropic API key,
claude-code probe failure, ktx cannot work without a database on resume,
Run in a TTY, or pass --target <target>. with a misleading exit 1, and a
secret that resolves empty only during ktx ingest/ktx mcp), see
troubleshooting.md.
Final report
End setup work with a concise report:
ktx SETUP COMPLETE
Project: <path>
LLM: <backend> / <model>
Embeddings: <backend> / <model>
Connections: <name> (<driver>) status=<ok|warn|fail>
Sources: <list or none>
Verdict: <ready|needs action>
Next:
1. <copy-pasteable command or action>
2. <copy-pasteable command or action>
RESULT: PASS


