omni-model-builder

Create and edit Omni Analytics semantic model definitions — views, topics, dimensions, measures, relationships, and query views — using YAML through the Omni…

npx skills add https://github.com/exploreomni/omni-agent-skills --skill omni-model-builder

Omni Model Builder

Create and modify Omni's semantic model through the YAML API — views, topics, dimensions, measures, relationships, and query views.

Tip: Always use omni-model-explorer first to understand the existing model.

Prerequisites

# Verify the Omni CLI is installed — if not, ask the user to install it
# See: https://github.com/exploreomni/cli#readme
command -v omni >/dev/null || echo "ERROR: Omni CLI is not installed."
# Show available profiles and select the appropriate one
omni config show
# If multiple profiles exist, ask the user which to use, then switch:
omni config use <profile-name>

# Confirm the active profile is authenticated and inspect your permissions:
omni whoami whoami

Auth: a profile authenticates with an API key or OAuth. If whoami (or any call) returns 401, hand off — ask the user to run ! omni config login <profile> (OAuth 2.1 browser flow; it blocks ~2 min on the browser). Don't run config login yourself in a headless/CI session (no browser → timeout); on a local interactive machine you may. See the omni-api-conventions rule for profile setup (omni config init --auth oauth) and discovering command and request-body shapes with --schema.

You need Modeler or Connection Admin permissions. Add -o json to any command to force structured output for parsing (default auto is human in a TTY, JSON when piped).

Omni's Layered Modeling Architecture

Omni uses a layered approach where each layer builds on top of the previous:

  1. Schema Layer — Auto-generated from your database. Reflects tables, views, columns, and their types. Kept in sync via schema refresh.
  2. Shared Model Layer — Your governed semantic model. Where you define dimensions, measures, joins, and topics that are reusable across the organization.
  3. Workbook Model Layer — Ad hoc extensions within individual workbooks. Used for experimental fields before promotion to shared model.
  4. Branch Layer — Intermediate development layer. Used when working in branches before merging changes to shared model.

Key concept: The schema layer is the source of truth for table/column structure (refreshed when the database changes); all user-created content (dimensions, measures, relationships, topics) flows through the shared model layer. You build and modify it in branches (see "Safe Development Workflow" below) before merging back to the shared model.

Determine SQL Dialect

Before writing any SQL expressions, confirm the dialect from the connection — don't guess from the connection name:

# 1. List models to find connectionId
omni models list

# 2. Look up the connection's dialect
omni connections list
# → find your connectionId and read the "dialect" field
# → e.g. "bigquery", "postgres", "snowflake", "databricks"

Use dialect-appropriate functions in your SQL (e.g. SAFE_DIVIDE for BigQuery, NULLIF(a/b) for Postgres/Snowflake).

Creating a new SHARED model (rare). Most work is on an existing model — but if you do create one with omni models create, the body is { modelKind: "SHARED", connectionId } (no baseModelId; it inherits the connection's schema views + assumed relationships — run omni models create --schema for the full field list). Footgun: create takes modelName, update takes name. Passing name on create is silently ignored and the model is named from the connection — then you'd have to omni models update <id> --body '{"name":"…"}' to fix it. Pass modelName on create and skip the rename.

Deleting a model (CLI ≥ 1.2.2). omni models delete <modelId> trashes a SHARED or shared-extension model together with the workbooks, dashboards, and child extension models built on it — confirm that blast radius with the user before running it. Requires Connection Admin. It does not delete branches: use omni models delete-branch for those.

Schema Refresh: Syncing with Database Changes

The schema layer is auto-generated from your database. When your database schema changes (new/deleted/renamed columns, type changes), refresh it to stay in sync: omni models refresh <modelId> (add --branch-id <branchId> to scope to a branch; requires Connection Admin).

See references/schema-refresh.md for when to trigger, what it does and its side effects, the deleted/renamed-column impact-check workflow, and connection/credential error handling.

Known Issues & Safe Defaults

🛑 HARD STOP: never merge/promote a branch on your own initiative

omni models merge-branch (and any merge / promote / ship to the shared model) is a SEPARATE, USER-INITIATED step. Do not run it unless the user has told you — in this conversation — to merge, ship, publish, promote, or "make it live." Preparing a branch (create → write YAML → validate → test) is the whole job for a "build / add / model this" request; shipping it is a distinct decision the user owns.

None of these count as merge permission — do not let them talk you past this gate:

  • "the change is additive / small / low-risk / can't break anything"
  • "the spec (or ticket, or doc) says to model it and publish"
  • "the user gave a broad 'build the whole thing' directive"
  • "it's required for the deliverable to work" / "the dashboard won't resolve until I merge"
  • "it's just a playground / sandbox / non-git model"

When the field/view is needed for downstream content but not yet merged, the correct move is a branch-bound draft (omni-content-builder → branch-bound drafts), not a merge. When a merge is genuinely needed, stop and ask ("Ready for me to merge <branch> into the shared model?") and wait for an explicit yes. If a harness/permission layer blocks the merge, that is the guardrail working — surface it and ask; do not look for another path around it.

  • Keep eval-created files on branches until confirmed — when you create fields/views for validation, report the branch name/id, validation status, and test-query result; merging is governed by the HARD STOP above.

Discovering Commands

omni models --help                # List all model operations
omni models yaml-create --help    # Show flags for writing YAML
omni models yaml-create --schema  # Print the body's JSON schema + a filled example (no token)

Safe Development Workflow

Always work in a branch — never write directly to production — and first check you can branch. Branching requires full-model access. Run omni whoami whoami --model-id <modelId>: QUERY_FULL_MODEL present → you can create a branch (and UPDATE present → you can merge/promote it, else open a PR / request a merge); absent → you can't branch — a one-off field belongs in the document's workbook model instead (omni-content-builder → Updating a Dashboard's Model). Full permission→capability map: omni-admin → Model Roles & Caller Access.

Step 0: Create a Branch

omni models create-branch <modelId> --name "my-feature-branch"

The response model.id is your branchId — a UUID you'll pass to all subsequent API calls. To list existing branches at any time:

omni models list --include activeBranches

⚠️ Git-connected models — never hand-edit model YAML in git. The repo is a projection of Omni's model for governance (PRs, review, audit) — not the source of truth and not a surface to author against. Omni regenerates the default branch from its own authoritative state and deletes git-only model files that state doesn't contain — an observed customer incident where files committed only to git silently vanished on the next sync. So author through the Omni APIs on a branch → omni models commit (Step 3) → review/merge in your git provider; never push hand-edited model YAML directly.

Model content vs. repo governance — the split that makes this safe:

  • Omni model content (view / topic / relationship YAML): author on an Omni branch → omni models commit → PR. Never commit it directly in git — Omni will regenerate over it.
  • Repo-governance / non-model files (a root omni/OWNERS.yaml, CODEOWNERS, CI config, docs, scripts): not Omni model content, so direct git commits are fine — Omni's regeneration leaves them untouched.

Step 1: Write YAML to a Branch

omni models yaml-create <modelId> --body '{
  "fileName": "my_new_view.view",
  "yaml": "dimensions:\n  order_id:\n    primary_key: true\n  status:\n    label: Order Status\nmeasures:\n  count:\n    aggregate_type: count",
  "mode": "extension",
  "branchId": "{branchId}",
  "commitMessage": "Add my_new_view with status dimension and count measure"
}'

Note: The branchId parameter must be a UUID from the server (Step 0). Passing a string name instead will return 400 Bad Request: Unrecognized key: "branchName".

⚠️ Editing an existing file = whole-file read-modify-write at its exact path. yaml-create replaces a file's authored content (no field-by-field merge), so yaml-get it first, edit, and write the complete file back, or the other authored fields are dropped. fileName is the file's exact path (not a regex, unlike on read) — reuse the full-path key verbatim, folder prefix included (e.g. MARTS/fct_ai_events.view); a non-matching name doesn't error, it silently creates a duplicate at that path (success: true). (Schema base columns live in the schema layer, so they're unaffected.) To inspect a branch, yaml-get without --file-name enumerates the whole model — --mode extension for just the branch's changed files (your deltas), --mode combined for the full composed model (schema + shared + branch); then drill into any file by its exact path.

dbt-connected models: omni models branch-dbt-get <modelId> <branchName> (CLI ≥ 1.1.2) reads the dbt environment a branch resolves to, including the dbt git branch it compiles against. A branch with no environment of its own resolves to the connection's default (production) environment, reported with is_default_environment: true.

Step 2: Validate and Test

Every YAML write must be validated and tested before merging — a field can be valid YAML yet produce wrong results or broken queries.

# 1. Validate — a NEW issue with is_warning:false that references your changed file is blocking; fix before proceeding
omni models validate <modelId> --branch-id <branchId>

# 2. Query the fields you changed — confirm no error, and cache_metadata.num_rows > 0
#    (the row count is at cache_metadata.num_rows; there is NO summary.row_count — see omni-query)
omni query run --body '{"query":{"modelId":"<modelId>","table":"your_view","fields":["your_view.new_dimension","your_view.new_measure"],"limit":10,"join_paths_from_topic_name":"your_topic"},"branchId":"<branchId>"}'

# 3. Read it back — confirm the field is present (and not duplicated at a second path)
omni models yaml-get <modelId> --file-name your_view.view --branch-id <branchId>

Triage validation errors — most aren't yours. On a fresh/inherited model, blocking not found errors (missing table/view/column) usually mean a stale schema: run a schema refresh (omni models refresh <modelId>) first — it clears them; a not found that persists after refresh is real (connection lacks DB access, or a genuinely broken reference). For what remains, baseline before your change (or filter issues by yaml_path) and treat as blocking only the new errors referencing your changed files — report the pre-existing ones, don't chase them.

Spot-check that values look right (a sum isn't returning a count; booleans read true/false), and if a field references another view include fields from both to confirm the join resolves. See references/validation-and-testing.md for join-path testing, natural-language validation via omni ai job-submit, the full results checklist, and duplicate-file recovery.

Window-shaped result columns are table calculations, not model fields. A running total, moving average, period-over-period / MoM %, percent-of-total, or rank is computed per query on the result set — author it as a table calculation (query.calculations[]) per omni-query → references/table-calculations.md, not as a measure/dimension here. Only model an in-warehouse field when the window must span rows outside the result set.

Step 3: Ship the Branch

Important: Always ask the user for confirmation before shipping. Changes applied to the production model cannot be easily undone. Only ship after validation and testing pass (Step 2).

Check whether the model is git-connected — omni models git-get <modelId>. A config with sshUrl / baseBranch → git-connected → Path A (open/update a PR); a 404/no config → not git-connected → Path B (merge directly in Omni).

Path A — Git-connected: open or update a PR

Push the branch contents to git. Creates a new git branch + PR if one doesn't exist; otherwise updates the existing PR:

omni models commit <modelId> --body '{
  "branch_id": "<branchId>",
  "commit_message": "Add my_new_view with status dimension and count measure"
}'

Surface the returned pr_url to the user. The reviewer merges the PR in your git host; changes flow back to baseBranch on the next sync. Run omni models commit --help for optional body flags (allow_branch_exists, require_branch_exists) when you need to enforce open-only or update-only behavior.

Path B — Not git-connected: merge in Omni

omni models merge-branch <modelId> <branchName>

After the merge — verify net-new topics and views (both paths)

After the merge, Omni regenerates the default branch from its own (authoritative) model state — re-serializing to canonical form, and adding a normalization commit only when the merged git content differs from that state. Content authored through the Omni APIs is already canonical, so a clean merge often adds no extra commit — don't go hunting for one; verify by resolution instead. (A non-git merge promotes the branch into the shared model.) Either way — and especially for net-new topics or views — confirm the files resolve against the production model (no --branch-id):

# 1. Files resolve in production — the new view appears / the new topic resolves
omni models get-views <modelId>                 # new view is listed
omni models get-topic <modelId> <topicName>     # new topic resolves (base_view_name + join_via_map present)

# 2. Production model validates — no blocking errors (any is_warning:false is blocking)
omni models validate <modelId>

# 3. Run at least one semantic query against the new topic/view
omni query run --body '{"query":{"modelId":"<modelId>","table":"<base_view>","fields":["<base_view>.<field>"],"limit":10,"join_paths_from_topic_name":"<topicName>"}}'

In the query response, confirm summary.missing_fields is [] (and summary.invalid_calculations is empty — note it comes back as {}, not []). A non-empty missing_fields means a field didn't resolve — the signature of a model file dropped or renamed during the merge. If anything is missing, re-author it through Omni; never patch it back by hand in git.

YAML File Types

TypeExtensionPurpose
View.viewDimensions, measures, filters for a table
Topic.topicJoins views into a queryable unit
Relationships(special)Global join definitions
Composite topic.composite_topicJoins two or more topics on shared dimensions (see composite-topics.md)

Write with mode: "extension" (shared model layer). To delete a file, send empty yaml.

Writing Views

Every view that participates in joins MUST have a real primary_key: true dimension. Without a genuine row-unique primary key, queries that join to this view can produce fanout errors or incorrect aggregations. Use the table's natural unique identifier (e.g., id, order_id, user_id). If no single column is unique, build a composite key from row-level columns that are jointly unique, for example sql: ${order_id} || '-' || ${line_number}. If you cannot define a row-unique expression, do not mark a dimension as primary_key: true yet; fix the grain first or avoid joining the view until a real key exists.

Why the PK matters mechanically — symmetric aggregates. Omni keeps sum/count/avg correct under a one-to-many join by deduplicating on the primary key (symmetric aggregates). A view missing a PK can't be made symmetric, so a count-of-rows measure on it inflates whenever a join duplicates its rows; a count_distinct measure survives only because it dedupes by construction. Diagnostic heuristic: when a measure exceeds a measure it should be a subset of (e.g. "sessions that viewed a product" > "total sessions"), suspect fanout from a non-distinct count on a view with no/weak PK — fix the PK, don't patch the number. Audit every base view for a PK (one missing PK is enough to break one viz). Note that a dashboard filter/control can pull in a join the topic never declared, via the global relationships graph, to satisfy its field — so even a "single-topic" tile can fan out; the PK is what keeps the aggregates safe regardless.

Basic View

dimensions:
  order_id:
    primary_key: true
  status:
    label: Order Status
  created_at:
    label: Created Date
measures:
  count:
    aggregate_type: count
  total_revenue:
    sql: ${sale_price}
    aggregate_type: sum
    format: currency_2

Understanding Schema Layer vs Extension Layer

When you create a view, Omni separates schema (database structure) from model (your business logic):

  • Schema layer: Auto-generated base dimensions, one per column. Types come from the database. Read-only, synced via schema refresh.
  • Extension layer: Your custom YAML. Can override base dimensions, add new dimensions/measures, hide columns, add business logic.

When both layers exist for a field with the same name, your extension definition wins but type information comes from the schema layer.

Example: Table has columns created_at (DATE) and revenue (NUMERIC).

# Schema layer (auto-generated)
dimensions:
  created_at: {}  # type: DATE, auto-generates timeframes
  revenue: {}     # type: NUMERIC

# Extension layer (your YAML)
dimensions:
  created_at:
    label: "Order Created"
    description: "When the order was placed"

  revenue:
    hidden: true  # Hide the raw column

measures:
  total_revenue:
    sql: ${revenue}
    aggregate_type: sum
    format: currency_2

Result: created_at inherits its type from the schema layer (DATE with automatic week/month/year granularities) but gets your label. The raw revenue column is hidden, only exposed through the total_revenue measure.

Key insight: If your extension defines a dimension but there's no schema layer base dimension to provide type information, Omni can't infer granularities or types. Trigger a schema refresh to auto-generate the schema layer first.

Reading back what you wrote — --mode. yaml-get returns your extension layer by default — just the deltas you authored, not the auto-generated base columns. To see the fully-composed result (schema base + your extension merged), read with --mode combined:

# What you authored (deltas only) — default
omni models yaml-get <modelId> --file-name your_view.view --branch-id <branchId> --mode extension

# What the model actually resolves to (schema + extension merged)
omni models yaml-get <modelId> --file-name your_view.view --branch-id <branchId> --mode combined

Use extension to confirm what you changed, and combined to confirm what the model resolves to. When the model is git-integrated, the combined output mirrors what's written to the repository — which is why committed *.view.yaml files carry the schema-layer table_name: and base columns, while the extension layer holds only your deltas. (Other --mode values: staged, merged, history.)

Dimension Parameters

See references/modelParameters.md for the complete list of 35+ dimension parameters, format values, and timeframes.

Most common: sql, label, description (also used by Blobby), primary_key (unique key — critical for aggregations), hidden (hides from picker, still usable in SQL), format (number_2/currency_2/percent_2/id), group_label, synonyms (AI-matching aliases). Two gotchas: a raw column auto-maps by name (no sql: needed); there is no ${TABLE} construct — ${TABLE}.column errors Column "__omni_scoped" not found at validate/query time (reference fields via ${field}/${view.field}).

Measure Parameters

See references/modelParameters.md (24+ params, all 13 aggregate types) and references/yaml-filter-syntax.md (filter operators + measure-filter examples). Prefer a measure filters: block for filtered aggregates over CASE WHEN/WHERE in sql — keep sql focused on the value being aggregated:

For level-of-detail aggregates (fixed / always_include / always_exclude, on dimensions and measures) see references/level-of-detail.md.

measures:
  completed_revenue:
    sql: ${sale_price}
    aggregate_type: sum
    filters:
      status:
        is: Complete

Filter-only fields and dynamic fields

Reach for a filter-only field when one user choice should change how a field is computed rather than which rows are kept: report by created date or shipped date, show revenue or order count in the same KPI, apply a threshold the viewer sets. It is a view-level filters: entry with no column that other fields read through Mustache. Don't reach for it to hide or show fields (use topic fields:), to filter rows (an ordinary filter), or when the two variants deserve their own topics (see "new topic vs extend"). Shapes, binding, validation: templated-filters.md.

Aggregate tables (aggregate awareness)

Reach for an aggregate table when the same fact rollup is queried repeatedly at a coarser grain than the table (daily or monthly tiles over an event-level fact) and a pre-aggregated table exists or can be built. Declare it with a materialized_query block on a view, one per table, described against the base view; Omni reads it when a query fits and falls back to the fact table when not. It will not help a query that needs a column the table lacks, a non-additive measure (average, count_distinct, median) at another grain, or a finer grain than the table. Confirm with a planOnly query headed -- Query rewritten to use materialized view. Rules, filter pins, joined views: aggregate-awareness.md.

Level of detail

Reach for level_of_detail when a measure has to be computed at a grain other than the query's: a per-customer total on every order row (fixed:), a finer aggregate summarized up (always_include:), or an amount held at a coarser grain while finer dims are on the report (always_exclude:). Name the grain the report actually uses — fixed: is flat across everything not listed only while the listed field is on the report, and listing a field the report does not use re-splits the measure. always_exclude adapts to whichever coarser field is present and tolerates over-listing, but needs an idempotent outer aggregate (max, not sum). It will not cap a level via always_include, and the lists take field references only — no wildcards. For a header amount repeating across line detail, weigh it against a composite topic with unrelated_dimension_handling: repeat, which needs no list. Grain matching, the outer-aggregate rule, template reuse and verification: level-of-detail.md.

Cross-View Fields in Views

Cross-view fields (dimensions or measures whose sql references ${other_view.field}) in a global view file are evaluated in every topic that exposes the host view. A global relationship can make the dependency reachable in topics that inherit the global join graph, but it does not override a topic's explicit/frozen joins: map. If that map omits the dependency, validation returns a blocking field_broken_in_topic issue and queries using the field fail to plan.

Placement follows intended availability and resolved join context, not field namespace. Define the field in the topic's views.<host_view> block (see "Topic-Scoped View Definitions") when it is meaningful only in that topic or depends on a topic-specific, aliased, or frozen join. The field remains queryable as <host_view>.<field>; topic scoping limits where it is available, not the namespace the user requested.

Use the global view file when the field is universally meaningful and its dependency resolves unambiguously in every topic that exposes the host view. Before choosing, list those topics and inspect each resolved get-topic join_via_map — not only the global relationships file — then validate the branch and query at least one topic for each distinct join context. Topics without explicit joins may inherit a global relationship; topics with explicit joins may exclude it.

Fallback: View Missing from yaml-get

Before concluding that a view doesn't exist, always run this two-step check. yaml-get only returns views from currently-loaded schemas — views in offloaded or inactive schemas won't appear, but they're still available.

# 1. List all schemas the connection knows about (loaded, offloaded, and inactive)
omni models get-schemas <modelId>
# → {"schemas": ["ANALYTICS", "PUBLIC", "STAGING", ...]}

# 2. If the target schema appears in the list, load it explicitly
omni models yaml-get <modelId> --include-schemas PUBLIC

Rules for --include-schemas:

  • Accepts exactly one schema name per call — commas are rejected. Load schemas one at a time.
  • The response will contain only views from that schema; relationships to other schemas are preserved.
  • To scope to a branch, add --branch-id <id> to yaml-get or --branch-id <id> to get-schemas (flag names differ per command).

If the schema isn't in the get-schemas list at all, the connection likely doesn't have access or the schema isn't synced — check with a Connection Admin.

Writing Topics

Before writing a topic, verify all views you plan to reference actually exist. Run omni models yaml-get <modelId> and confirm each view appears. If a view is missing, run the lazy-load fallback above before concluding it doesn't exist — it may simply be in an offloaded schema.

New topic vs extend an existing one

When a query can't be answered by an existing topic, first check whether you should simply extend one rather than create a new one. Extending is usually right when the request's base view (the FROM) matches an existing topic's base view — e.g. add a relationship/join so a needed view becomes reachable, or add a field/label. Create a new topic when any of these is fundamentally different:

  • Subject / object — a different base view (FROM). What is the query fundamentally describing — orders? users? time (dates)? A different core entity warrants its own topic.
  • Constraints — conditions that are always applied (e.g. an always_where that excludes test users from order data). Different always-on filters → a different topic.
  • Audience — the same fields but different labels/terminology for a different consumer; jargon that differs by audience justifies a separate topic.

Prompt the requestor when it's a judgment call, and build new topics on a branch (see the Safe Development Workflow above). Note that querying on a topic (vs a bare base view) is also what makes the result accessible to restricted queriers/viewers.

See Topics setup for complete YAML examples with joins, fields, and ai_context, and Topic parameters for all available options.

Key topic elements:

  • base_view — the primary view for this topic
  • joins — nested structure for join chains (e.g., users: {} or inventory_items: { products: {} })
  • ai_context — guides Blobby's field mapping (e.g., "Map 'revenue' → total_revenue")
  • default_filters — applied to all queries unless removed
  • always_where_sql — non-removable WHERE filter using a SQL expression (cannot be removed by users)
  • always_where_filters — non-removable WHERE filter using filter specifications (cannot be removed by users)
  • always_having_sql — non-removable HAVING filter using a SQL expression, applied after aggregation (cannot be removed by users)
  • always_having_filters — non-removable HAVING filter using filter specifications, applied after aggregation (cannot be removed by users)
  • fields — field curation: [order_items.*, users.name, -users.internal_id]

Filter Expressions for Topics

When configuring default_filters, always_where_filters, or always_having_filters on a topic, use the YAML filter condition syntax — the same syntax used in measure filters. See references/yaml-filter-syntax.md for the complete reference.

If the right filter configuration for a given use case isn't obvious, use the Omni AI CLI to search the docs:

omni ai search-omni-docs "how do I configure always_where_filters on a topic in Omni?"

Use targeted questions to get precise YAML examples for your specific filtering need before writing the model YAML.

Composite topics

Reach for a composite topic when one query must place measures from two facts that share dimensions but no join (sales and returns by month), or one fact under two conditions (this period and last, created basis and shipped basis). Cardinality decides: a filter table that is many-to-one from the summed fact is a dimension, join it; one-to-many is a query view rolled up to the summed grain; only unjoined facts need a composite. Don't reach for it when one topic with a join answers the question, or when the second measure is a filtered variant of the first. Authoring, the two-lens extends pattern, and the @-addressed query shape: composite-topics.md.

Writing Relationships

Global Relationships

Global relationships are defined in the shared relationships file and are available across all topics. Use these for standard, reusable joins.

- join_from_view: order_items
  join_to_view: users
  on_sql: ${order_items.user_id} = ${users.id}
  relationship_type: many_to_one
  join_type: always_left
TypeWhen to Use
many_to_oneOrders → Users
one_to_manyUsers → Orders
one_to_oneUsers → User Settings
many_to_manyTags ↔ Products (rare)

Getting relationship_type right prevents fanout and symmetric aggregate errors.

Topic-Scoped Relationships

Before defining, check the global relationships file for a join between the same two views in either direction. Same on_sql → redundant, use joins: only. Different on_sql → default to the extended views pattern below rather than a silent override. Confirm intent with the modeler.

Use topic-scoped relationships for one-off joins not in the shared model, or joining the same table multiple times under different conditions.

# .topic file
relationships:
  - join_from_view: order_items
    join_to_view: users
    on_sql: ${order_items.user_id} = ${users.id}
    relationship_type: many_to_one
    join_type: always_left

joins:
  users: {}

joins vs relationships: joins declares which views are in the topic and their hierarchy; relationships defines the join conditions. A topic using only global relationships needs only joins. A topic with a one-off join needs both.

Silent footgun — both are required, and omitting joins passes validation. If you add the relationships entry but forget the view's joins entry, the model still validates clean (no error) — but the joined view's fields are silently not exposed in the topic (a query/markdown referencing them comes back empty). Don't trust validate for this; confirm the fields are actually exposed with get-topic (see validation-and-testing.md). (Topic file shapes vary: some list views under a joins: map, others as top-level <view>: {} entries — match the existing file's form.)

Extended Views: Joining the Same Table Multiple Ways

When the same table needs multiple joins (e.g., users as buyer and seller), use the extended views pattern — not join_to_view_as. Two variants:

Variant 1 — Global (reusable): Create a standalone .view file with extends:, a role-descriptive name, and a description:. Define the relationship globally — any topic can then join it like any other view.

Variant 2 — Topic-scoped (inline): Define the alias in the topic's views: block with its relationship in the same file. Use when the alias is not generally applicable in other topics.

See references/topic-scoped-relationships.md for full YAML examples of both variants.

If you see a relationship alias duplicates view name error, this pattern is the fix.

Topic-Scoped View Definitions

Topics can define or override views inline using a views: block — controlling display_order, overriding label, adding topic-specific filtered measures or derived dimensions, defining cross-view fields, and joining the same view multiple ways with per-alias conditions.

Before adding any topic-scoped field to an existing view:

  1. Read the view YAML (omni models yaml-get) and confirm the field doesn't already exist. If it does with the same definition, skip it.
  2. If a field with the same name exists but uses different SQL, this is an override. Confirm explicitly with the modeler — queries through this topic will use the topic-scoped definition; all other topics keep the shared one.
# Example: display order + topic-specific filtered measure
views:
  order_items:
    display_order: 0
    measures:
      us_revenue:
        sql: ${sale_price}
        aggregate_type: sum
        format: currency_2
        filters:
          users.country:
            is: US

See references/topic-scoped-views.md for a full pattern gallery (label overrides, derived dimensions, cross-view fields, multi-join lifecycle, topic-scoped query views).

Cross-view fields in views: blocks: Before writing ${view_name.field_name} references, confirm every referenced view is declared in the topic's joins: block — the model validator throws errors for any reference to a view that isn't joined.

Joining the same view multiple ways (e.g., ARR at Start / Current / End): Use extends: inside the topic's views: block to create named aliases, each with its own on_sql in relationships:. Each alias inherits all base view fields and can override labels independently. For a full YAML example, see references/topic-scoped-views.md.

Topic-scoped query views: A query view can also be defined inside a topic's views: block, scoping it to that topic only. Same primary key rules apply (primary_key: true or custom_compound_primary_key_sql). Include a relationships: entry and a joins: entry for the new view — see Query Views section above, and references/topic-scoped-views.md for a complete example.

Query Views

Virtual tables defined by a saved query. A query view must have a primary key or it cannot be joined without producing fanout errors. Before writing, confirm which field uniquely identifies each row — unless the primary key can be clearly inferred from the query itself and the involved views (e.g. a query that selects user_id from a users view where user_id is the known primary key).

There are two ways to define the primary key:

Option 1 — Single unique field: Mark exactly one dimension primary_key: true in the dimensions: block.

Option 2 — Compound key: When no single field is unique but a combination is, set custom_compound_primary_key_sql: [field_a, field_b] at the view level — no primary_key: true dimension needed.

Both options work with either a query: block (field-mapped virtual table) or a sql: block (raw SELECT). In sql: blocks, use ${view_name} to reference a view's underlying table rather than a hard-coded CATALOG.SCHEMA.TABLE path — it's preferred and stays correct if the table moves. See references/query-view-examples.md for complete YAML for each variant.

If the user is unsure which field is unique, ask before writing the view. A query view without a primary key will trigger a "Joins fan out the data without a primary key" error when joined. See: https://community.omni.co/t/why-am-i-getting-the-error-joins-fan-out-the-data-without-a-primary-key/37

Query views can also be defined inline within a topic's views: block, scoping the virtual table to that topic only. See references/topic-scoped-views.md for an example.

sql: versus query: for a rollup. Field references (${view.field}) work inside a sql: block, but Omni expands them once, when the file is saved, into alias-qualified columns, so the FROM must be aliased to the view's reference name (FROM ${order_items} AS "order_items"). Validation does not catch a missing alias; a query does. The query: form (fields: mapping view fields and measures to column names, plus base_view and topic) compiles through the model on every run, so later changes to those fields flow through. A sql: query view gets no automatic count measure; declare one if it is needed. Examples in references/query-view-examples.md.

Common Validation Errors

ErrorFix
"No view X"Check view name spelling
"No join path from X to Y"Add a relationship
"Duplicate field name"Remove duplicate or rename (or suppress with hidden: true if one is auto-generated)
"Invalid YAML syntax"Check indentation (2 spaces, no tabs)
Fanout / incorrect aggregations on joinsAdd primary_key: true to the joined view — every view that participates in a join must have a primary key
Column reference error (e.g., "Column X not found")Check that the table exists and your Omni connection has access
Duplicate view appeared at the repo root after an editYou wrote with a bare fileName instead of the file's full path. Delete the stray root file (send empty yaml to it) and re-write using the exact files key, including its folder prefix (e.g. MARTS/)

Troubleshooting: Model Out of Sync with Database

If the model doesn't reflect the database (missing columns/tables, wrong types, broken references), trigger a schema refresh (see "Schema Refresh" above), then omni models validate <modelId>. Field-name collisions and broken column references are usually fixed with hidden: true or a rename (see "Common Validation Errors"); persistent missing tables mean the connection lacks access to that database/schema.

Docs Reference

Related Skills

  • omni-model-explorer — understand the model before modifying
  • omni-ai-optimizer — add AI context after building topics
  • omni-query — test new fields

More skills from exploreomni

omni-admin
exploreomni
Administer an Omni Analytics instance — manage connections, users, groups, user attributes, permissions, schedules, and schema refreshes via the Omni CLI. Use…
omni-ai-eval
exploreomni
Evaluate Omni AI query generation accuracy by running test prompts through the Omni CLI, comparing generated query JSON against expected results, and scoring…
omni-ai-optimizer
exploreomni
Optimize your Omni Analytics model for Blobby, the Omni Agent — configure ai_context, ai_fields, sample_queries, and create AI-specific topic extensions. Use…
omni-content-builder
exploreomni
Create, update, and manage Omni Analytics documents and dashboards programmatically — document lifecycle, tiles, visualizations, filters, and layouts — using…
omni-content-explorer
exploreomni
Find, browse, and organize content in Omni Analytics — dashboards, workbooks, folders, and labels — using the REST API. Use this skill whenever someone wants…
omni-model-explorer
exploreomni
Discover and inspect Omni Analytics models, topics, views, fields, dimensions, measures, and relationships using the Omni REST API. Use this skill whenever…
omni-query
exploreomni
Run queries against Omni Analytics' semantic layer using the REST API, interpret results, and chain queries for multi-step analysis. Use this skill whenever…
omni-content-explorer
exploreomni
Find, browse, and organize content in Omni Analytics — dashboards, workbooks, folders, and labels — using the REST API. Use this skill whenever someone wants…