SKILL.md
Segments
A segment is a saved audience: one row per record of a root cube, kept when it matches a filter. It's defined over the semantic model (cubes), not over a table — tables only borrow a segment to scope their rows.
You reach it through the RevOS MCP server's generic tools on the segments
resource: api_read lists and gets, api_write creates (no id) and updates
(with id). api_details({ resource: "segments", operation: "create" }) is
the authority on the body shape; this skill covers what that schema can't
tell you.
The thing to keep in mind throughout: the server barely validates a segment when you save it. Member names, join paths, and whether a condition makes sense for the root are only checked by the RevOS UI's segment editor. Over the API a broken segment saves with a 200 and then fails, or worse, quietly matches the wrong records when it's evaluated. So the checks below are yours to do before writing — and reading the segment back after writing is how you find out what was actually saved.
Calling the tools
The argument shapes matter more than usual here, because a key in the wrong place is often ignored rather than rejected — a write with its body under the wrong key can return success, bump the segment's version, and change nothing.
// List / get / named reads — `params` for query-string filters, `body` for a named read that takes one
api_read { "resource": "segments", "params": { "filter": "name == \"Enterprise accounts\"" } }
api_read { "resource": "segments", "id": "<segment id>" }
api_read { "resource": "cubes", "method": "meta", "params": { "filter": "name == \"crm_deals\"" } }
api_read { "resource": "cubes", "method": "query",
"body": { "query": { "dimensions": ["crm_deals.stage"], "limit": 20 } } }
// Create (no id) / update (with id) — the record always goes under `data`
api_write { "resource": "segments", "data": { "name": "…", "type": "DYNAMIC", "cubes": [ … ], "filter": { … } } }
api_write { "resource": "segments", "id": "<segment id>", "data": { "filter": { … } } }
method (not operation) selects a named read like meta or query; a
cubes query goes under body.query, never params.
Creating a segment
1. Pick the root cube — it decides what one "member" is
"Companies that have a won deal" has companies as members; "won deals" has deals as members. Same data, different audience. Choose the cube whose rows are the things the user wants to end up with, and confirm with the user when it's genuinely ambiguous — the root can't be changed after creation (doing that means making a new segment).
Read the catalogue first, then the root's full definition:
{ "resource": "cubes", "method": "meta", "params": { "fields": "name,title,description", "pageSize": 100 } }
{ "resource": "cubes", "method": "meta", "params": { "filter": "name == \"crm_companies\"" } }
The full definition gives you the members (always written
<cube>.<member>), each dimension's type, which dimension has
primaryKey: true, meta.nameDimension when the cube declares one, and the
cube's joins. Never guess a member name — they're per-organization.
Prefer business-named cubes over generated plumbing — helper cubes with a sparse or missing description and a mechanical-looking name — unless the user is clearly asking about one of those.
2. Build cubes — the root first, then one entry per other cube you filter on
"cubes": [
{ "joinPath": "crm_companies", "primaryKeyDimension": "id", "nameDimension": "name" },
{ "joinPath": "crm_companies.crm_deals" }
]
cubes[0]is the root. ItsjoinPathis just the cube name. SetprimaryKeyDimensionto the root's primary-key dimension (the member name without the cube prefix) andnameDimensiontometa.nameDimensionwhen the cube declares one, otherwise to a dimension literally callednameif the cube has it — table creation from a segment uses both to build itsidandnamecolumns. If the root has no primary-key dimension, say so: the segment will evaluate onid, which may not exist.- Every other cube a condition references gets an entry whose
joinPathis the dotted path from the root to it, followingjoins:"root.middle.target". Check each hop against a cube'sjoins— and remember a cube only lists joins it declares, so ifAdoesn't listB, look atB's joins too before concluding there's no path. - When there are two genuinely different routes to the same cube, the path you pick changes the answer. Don't silently choose — ask which relationship the user means.
3. Write the filter — and watch for fan-out
filter is RevOS's own condition format (not a Cube.js query):
"filter": {
"combinationMode": "AND",
"items": [
{ "member": "crm_companies.industry", "type": "EQUAL", "value": "Software" },
{ "filterType": "Group", "combinationMode": "OR", "items": [
{ "member": "crm_companies.country", "type": "EQUAL", "value": "DE" },
{ "member": "crm_companies.country", "type": "EQUAL", "value": "AT" } ] }
]
}
The operator vocabulary, which operators fit which member types, date values,
and the validation rules the JSON schema doesn't show are in
references/filter-syntax.md — read it before
writing any condition beyond a simple EQUAL. Two rules come up constantly:
there is no multi-value "in" (value is one string or number, so "DE or
AT" is an OR group as above), and IS/IS_NOT only take TODAY,
IN_THE_FUTURE, or IN_THE_PAST.
Check the stored values before any EQUAL / NOT_EQUAL on a string.
Matching is exact and case-sensitive, and the user's words ("active",
"enterprise", "won") are often not how the source stores them (ACTIVE,
Enterprise, closedwon). A wrong value doesn't error — the segment just
comes out empty. One cheap query shows what's really there:
api_read { "resource": "cubes", "method": "query",
"body": { "query": { "dimensions": ["billing_subscriptions.status"], "limit": 20 } } }
Use the stored spelling, and if nothing matches the user's term, ask rather than guess.
Fan-out — the trap that saves fine and answers wrong. If the root has
many of another cube (the root's joins entry, or the reverse, says
hasMany/one_to_many), a dimension condition on that cube doesn't mean
"members that have at least one such row" in any reliable way — it fans the
root out across the child rows and the UI refuses to save it for exactly this
reason. The API won't stop you.
Rephrase the condition as a measure on the child cube instead, which aggregates per root row:
- "Companies with at least 3 deals" →
{ "member": "crm_deals.count", "type": "GREATER_THAN_OR_EQUAL", "value": 3 } - "Companies with any deal" →
crm_deals.countGREATER_THAN0.
If the child cube has no measure that expresses the condition (e.g. "has a deal in stage won" and there's no count-of-won-deals measure), don't write the fanning dimension condition anyway. Tell the user plainly that the semantic model doesn't support that condition from this root yet, and offer what it does support — a segment rooted on the child cube instead (won deals), or adding a measure to the cube.
Conditions on a cube the root has one of (hasOne/belongsTo) are fine
as dimension conditions.
4. Choose type — DYNAMIC unless the user insists
- DYNAMIC (use this by default): re-evaluated automatically by RevOS a
few times a day, so it follows the data. A new or edited DYNAMIC segment
shows
status: "PENDING"until the next scheduled evaluation — say so, so the user isn't surprised byobjectCountbeing empty right after saving. - STATIC freezes the membership at evaluation time — but evaluating is
something the MCP tools can't trigger. A STATIC segment created here stays
PENDINGuntil someone presses Evaluate in the RevOS UI, and while it's pending a table scoped to it shows all rows, unfiltered. Only create one when the user explicitly asks for a frozen snapshot, and tell them to run Evaluate in the UI before relying on it.
type can't be changed after creation.
5. Name it, write it, report back
name is required (1–255 chars); add a one-line description of the
audience in plain words. Before writing, check for a namesake —
api_read({ resource: "segments", params: { filter: "name == \"…\"" } }).
If one exists, ask whether they mean that segment before making a second.
Then api_write({ resource: "segments", data: { name, description, type, cubes, filter } }).
Read it back with api_read by the returned id and compare its
cubes and filter to what you sent. They should match; if they don't, the
write didn't land the way you intended — fix the call rather than reporting
success.
Then tell the user, in a sentence or two: the root (what one member is), the
conditions in plain language, the type, and that it's PENDING until
evaluated. If you made a judgment call (a measure instead of a fanning
dimension, a particular join path), say which and why.
Editing a segment
api_write with the segment's id and only the fields that change. The
catch: each field you send replaces the stored one whole. filter and
cubes are single fields — send a filter with one new condition and every
condition you left out is gone.
-
api_read({ resource: "segments", id })fresh, right before writing. -
Look up how any new value is stored before you add it. The user's word for it ("startup plan", "churned", "EMEA") came from conversation, not from the data, and the existing conditions only prove how their values are spelled. Query the dimension the new condition is on:
api_read { "resource": "cubes", "method": "query", "body": { "query": { "dimensions": ["crm_companies.plan"], "limit": 20 } } }Use the stored spelling. If no stored value matches what the user said, ask instead of writing a condition that matches nothing.
-
Start from the current
filter(andcubes) and change only what the user asked for. -
If the new condition references a cube that isn't in
cubesyet, add itsjoinPathentry and send the wholecubesarray too. If you removed the last condition on some cube, drop that entry. -
Don't send
type— it isn't updatable — and don't try to change the root (cubes[0]); for a different root, create a new segment. -
After the write,
api_readthe segment again and check thefilteris the one you meant to save — a bumpedversionalone doesn't prove the change landed.
Every update bumps the segment's version (the UI can restore older
versions — worth mentioning if the user is nervous about an edit). A STATIC
segment goes back to PENDING when its filter or cubes change.
Scoping a table's rows with a segment
Tables don't carry filters; their view does. To make a table show only a segment's members, point the table's default view at the segment:
api_read({ resource: "table-views", method: "list", id: "<TABLE id>" })— the list hangs off the table, so it's the table's id here. Take the entry withisDefault: true.api_write({ resource: "table-views", id: "<that VIEW's id>", data: { "segmentId": "<segment id>" } }).
A view holds either a segment or its own ad-hoc filter, never both; attaching
a segment is the better of the two. "segmentId": null detaches it. Use a
segment whose root cube is the cube the table's rows come from (its main
stream) — that's how the UI pairs them when it builds a table from a
segment. If the segment is STATIC and not READY, warn that the table will
show every row until it's evaluated.
Troubleshooting a segment
api_read the segment and look at status:
PENDING— saved, not evaluated yet. Normal right after a create or edit; DYNAMIC catches up on its own schedule, STATIC needs Evaluate in the UI.EVALUATING— in progress; check back.FAILED—evaluationErrorhas the reason, usually Cube's. The common causes, in order of likelihood: a member that doesn't exist (re-read the cubes'metaand correct the name), ajoinPathhop that isn't a real join, or a condition value of the wrong kind for its member. Fix the segment with an edit as above; it'll be re-evaluated on the next run.READYbut the wrong members or count — the usual suspects are a fanning dimension condition (see step 3), a root cube that doesn't match what the user meant by "member", or a join path that took the other route.
Deleting
api_delete({ resource: "segments", id }) removes a segment for good, and
for a STATIC one its snapshot of members too. There is no undo. Before the
call:
-
Read the segment and name it back: its type, root cube, conditions, and member count.
-
A STATIC segment's cube. Check this before anything else: it's what can make the delete impossible. A STATIC segment compiles into a cube,
segment_<id, - → _>. Look for that name in:- the tables'
streams, ids andjoinPaths (fields: "id,name,streams"): a table started from the segment is rooted on its cube; - the other segments'
cubesandfilter(full records); - the stored cubes'
joins(api_readoncubes,fields: "name,definition").
With the segment gone, any of these stops compiling, and the org's whole model goes down with it. Stop and report what you found, and don't change anything else for this delete yet.
- the tables'
-
Views. The delete is refused (
409) while any table view has the segment attached.usageCounton the segment is the number of them; at 0, skip ahead. Otherwise find them: list the tables, then each table's views (table-views,method: "list", the TABLE's id,filter: "segmentId == \"<segment id>\""), until you've foundusageCountof them. Tell the user which tables it scopes. Detaching (segmentId: null) makes those tables show every row, so detach only with their yes. -
Ask for an explicit yes for this segment. Approval to delete one doesn't cover another.
-
api_delete, then check thatmetastill compiles and the segment is gone from the list. Ifmetafails, there's nothing to restore: read the error, find what still names the segment's cube (the places in step 2), and tell the user what has to change.
A segment you created yourself in a failed attempt is no exception: say
so, and ask before deleting it. A 403 means the user's role can't delete
segments; someone with a higher role in the organization has to do it.