About query-semantic-model
A skill for answering business questions from an organization's semantic model with `revos cubes` and presenting results in chat. It guides users through member discovery, join selection, query execution, and table or chart rendering.
Key features
- 01Inspect cubes and queryable members
- 02Trace join paths in local YAML
- 03Build Cube.js queries with join hints
- 04Run queries through the `revos` CLI
- 05Render results as tables and charts
Use cases
- 01Check revenue by month or category
- 02Compare measures across semantic-model dimensions
- 03Avoid incorrect results from ambiguous joins
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ revosai/ skills --skill query-semantic-modelAfter installing, type/query-semantic-modelin Claude Code to confirm it loaded.
Details
From the project READMEQuery Semantic Model
Answer business questions by querying the org's semantic model and rendering the result
directly in the chat — no notebook, no UI hop. The CLI already exposes everything you
need: revos cubes meta lists the available cubes / measures / dimensions, and
revos cubes query runs a Cube.js query and returns the rows.
This skill is the "last mile" after Bronze → Silver → Gold → Cubes have been built and applied. It turns the semantic model into something you can actually look at.
Step 1: Discover what's queryable
Before guessing member names, list what the org's semantic model actually exposes:
revos cubes meta --json
The response carries an array of cubes, each with measures, dimensions, segments,
and a name. Cube members are addressed as <CubeName>.<member> — for example
gold_order_items_enriched.count or gold_order_items_enriched.order_date.
Pick the cube and members that match the user's question. If the question can't be answered from the available members, say so — don't invent member names.
Map the join graph from the local cube files
revos cubes meta tells you which members exist; the local cubes/ folder tells you
how cubes are connected. Read every *.yml file in cubes/ and pull out each
cube's joins: block — that's how the user-defined part of the semantic model is
wired up. Example:
# cubes/gold_order_items_enriched.yml
apiVersion: revos/v1
kind: Cube
metadata:
name: gold_order_items_enriched
spec:
name: gold_order_items_enriched
joins:
gold_users_with_order_stats:
relationship: many_to_one
sql: "${CUBE}.user_id = ${gold_users_with_order_stats}.user_id"
gold_product_performance:
relationship: many_to_one
sql: "${CUBE}.product_id = ${gold_product_performance}.product_id"
From this you can compute, for any pair of cubes, how many distinct join paths exist between them. That's what lets you pick the right path in Step 2 instead of letting Cube.js guess one for you.
Caveat — system cubes are not in cubes/. RevOS generates a handful of cubes
server-side (scoring, segments, model overlays, pre-aggregation overlays). Those
never appear as YAML in the project. The full member list is only visible through
revos cubes meta. So:
- Use the
cubes/folder to see the user-defined join graph. - Use
revos cubes metato see every member that can actually be queried, including system cubes.
When the user's question touches a system cube (e.g. "show me users by segment",
"top scoring entities"), the join graph from cubes/ will be incomplete. Don't
assume there's no path — list the meta and look for it there.
Step 2: Build the query — and pin the join path when it matters
A Cube.js query is a JSON object. The shapes you'll use most often:
{ "measures": ["gold_order_items_enriched.count"] }
{
"measures": ["gold_order_items_enriched.count"],
"dimensions": ["gold_order_items_enriched.traffic_source"],
"order": { "gold_order_items_enriched.count": "desc" },
"limit": 10
}
Time series (the chart-friendliest shape):
{
"measures": ["gold_order_items_enriched.revenue"],
"timeDimensions": [
{
"dimension": "gold_order_items_enriched.order_date",
"granularity": "month",
"dateRange": "last 12 months"
}
]
}
Rules of thumb:
- One measure + one dimension → bar chart by category.
- One measure + one
timeDimensionswithgranularity→ bar chart over time. - Multiple measures or multiple dimensions → render as an ASCII table only; don't try to draw a chart.
- Always cap with
limit(default 20) so a stray query can't return a million rows.
Join paths — and how to pin them with a hint
Cube.js infers the join path from the cubes that appear in measures /
dimensions / filters. When more than one path connects them, Cube.js picks
one for you — and the answer changes depending on which. This is the single
biggest source of "the number looks wrong" bugs in a semantic-model query.
You spell the path out by prefixing the member with the cubes you want Cube.js to walk through, dot-separated. The last segment is the actual member; everything before it is the join hint. Examples:
# no hint — Cube.js picks a path
gold_users_with_order_stats.lifetime_revenue
# hint: reach lifetime_revenue via the fact spine, then the users mart
gold_order_items_enriched.gold_users_with_order_stats.lifetime_revenue
The hint syntax works the same on measures, dimensions, and filter members.
When you need a hint (red flags):
- Two cubes in the query are connected by more than one path in
cubes/. Common shape: a fact spine joins to two dimension cubes, and one of the dimension cubes also joins to the other (e.g. orders → users, orders → products, products → users vialast_purchaser_id). Without a hint, Cube.js may pick the non-obvious path and quietly double- or under-count. - The user's question implicitly names the path ("revenue per order item by user country" vs "revenue per user by user country") — different grains, different paths, different numbers.
- Counts come back smaller than the smallest cube would allow, or larger than the largest fact would allow. That's a sign Cube.js fanned out through the wrong join.
- The cube has a many-to-many relationship in the chain. Always pin it.
- You added a measure and the number changed dramatically (more than ~10%) without the dimension set changing — Cube.js probably re-routed the join.
Worked example — same question, two answers.
Cubes (from cubes/):
gold_order_items_enriched ──many_to_one──▶ gold_users_with_order_stats
gold_order_items_enriched ──many_to_one──▶ gold_product_performance
gold_product_performance ──many_to_one──▶ gold_users_with_order_stats (last_purchaser_id)
Question: "Total revenue by user country."
// Path A — order items → users directly (CORRECT for per-item revenue)
{
"measures": ["gold_order_items_enriched.revenue"],
"dimensions": [
"gold_order_items_enriched.gold_users_with_order_stats.country"
]
}
// Path B — order items → product → last_purchaser (WRONG: revenue gets
// attributed to the last buyer's country, not the actual buyer's)
{
"measures": ["gold_order_items_enriched.revenue"],
"dimensions": [
"gold_order_items_enriched.gold_product_performance.gold_users_with_order_stats.country"
]
}
The two queries return wildly different numbers. Always state which path you used and why in the explanation underneath the chart so the user can spot a wrong choice.
Worked example — filter via a specific path.
{
"measures": ["gold_order_items_enriched.revenue"],
"dimensions": ["gold_order_items_enriched.traffic_source"],
"filters": [
{
"member": "gold_order_items_enriched.gold_users_with_order_stats.country",
"operator": "equals",
"values": ["DE"]
}
]
}
Rules for choosing a path:
- Default to the shortest path that goes through the fact spine the
question is naturally grained on (revenue per order → spine =
gold_order_items_enriched; users with X → spine =gold_users_with_order_stats). - Never silently take the alternate path. If two paths are plausible, ask the user which grain they meant before running the query.
- If
revos cubes metareturns a member name that already contains dots (e.g.cubeA.cubeB.foo), that is the qualified form — paste it through verbatim, don't strip the prefix.
Step 3: Run the query
revos cubes query --query '<INLINE_JSON>' --json
For anything longer than a one-liner, write the JSON to a temp file and pass it with
@:
cat > /tmp/q.json <<'EOF'
{
"measures": ["gold_order_items_enriched.revenue"],
"timeDimensions": [
{
"dimension": "gold_order_items_enriched.order_date",
"granularity": "month",
"dateRange": "last 12 months"
}
]
}
EOF
revos cubes query --query @/tmp/q.json --json
The response is the raw Cube.js payload — data is the array of rows, each row is a
flat object keyed by the same member names used in the query.
If the API returns an error, surface the message verbatim. Common causes: a member
name typo (re-check revos cubes meta), a cube that hasn't been applied yet
(revos status / revos apply), or a dateRange against a column that isn't a time
dimension.
Step 4: Render the result in chat
Print two blocks in the assistant's reply, both as fenced code so they render monospaced:
4a. ASCII table — always
Show every returned row. Right-align numeric columns, left-align everything else.
Format numbers with thousands separators; round to 2 decimals when not integral.
Truncate long string values to 32 chars with a trailing ….
traffic_source count
───────────────── ───────
Search 142,318
Organic 88,204
Email 41,907
Facebook 22,015
Display 9,471
4b. ASCII bar chart — when the shape allows
If the query returned exactly one measure plus exactly one dimension (categorical or
time), draw a horizontal bar chart underneath the table. Scale bars to a width of
40 characters based on the largest value in the result set; use the █ block
character for filled cells and a single trailing space.
Search ████████████████████████████████████████ 142,318
Organic ████████████████████████▊ 88,204
Email ███████████▊ 41,907
Facebook ██████▏ 22,015
Display ██▋ 9,471
Time-series example (oldest → newest, top-to-bottom):
2025-06 ███████████▊ € 41,200
2025-07 █████████████████▏ € 59,800
2025-08 ████████████████████▋ € 72,400
…
Skip the chart (table only) when:
- The result set is empty.
- The query has 2+ measures, 2+ dimensions, or a single scalar value (no dimension).
- All measure values are zero or null.
For a single scalar answer (one row, one column), just say the number in prose followed by the one-line table — no chart.
Step 5: Explain the result briefly
After the rendered output, add 1–3 short sentences in plain English: what was measured, the highest / lowest bucket, and any obvious anomaly (e.g. a missing month, a single category dominating the total). Keep it to facts that are visible in the table — don't speculate about causes.
If the user is likely to want to drill in, suggest one concrete follow-up query they can ask for next ("Want the same broken down by country?"). Don't list more than one — keep the chat tight.
Rules
- Never hardcode cube or member names. Always confirm them via
revos cubes metabefore composing the query. - Before composing a multi-cube query, scan the
joins:blocks incubes/*.yml. If the cubes you need are connected by more than one path, pick one explicitly with a dotted join hint instead of letting Cube.js guess. - Remember that system cubes (scoring, segments, model overlays) live only in
revos cubes meta, not incubes/. Their joins aren't visible in the YAML — only in the meta output. - Always render the table; render the chart only when the data shape supports it.
- Keep
limitset (default 20). For "top N" questions, setlimitto N and addorderon the measure. - Quote query JSON safely on the shell — prefer a temp file with
@/tmp/q.jsonover a long inline string with embedded quotes. - Don't write the query result to disk or invoke a Python plotter — this skill is
in-chat only. For static PNG visualizations of the cube graph itself, use
visualize-semantic-model. - If
revos cubes metareturns no cubes, stop and tell the user to runrevos applyfirst.