March 22, 2026

Managing Your Semantic Layer with Claude Code: A Skill for Context Engineering in Omni

This blog post was written with the assistance of Claude Code. Fitting, given the whole point,AI tooling turns tasks that used to be incredibly tedious into something you can ship in a fraction of the time.

AI chatbots can write SQL. But can they write the right SQL?

If you've ever pointed an AI assistant at a Salesforce dataset and asked "How many deals did we win from referral partners in FY25?", you've probably seen it filter on LeadSource = 'Partner Referral' and call it a day. That's wrong. In many Salesforce orgs, referral partner wins are tracked in a custom field like Competitor__c,a field named "Competitor" that actually stores the win attribution channel for won deals. No AI is going to figure that out from the column name alone.

This is the problem a semantic layer solves. And this post walks through how I built a Claude Code skill that deploys, validates, and manages that semantic layer in Omni,programmatically, with guardrails that prevent broken models from reaching production.

Here's a demo of what the environment looks like once everything is configured,the semantic layer deployed, the validator passing, and the AI chatbot answering questions correctly.

Repo: github.com/joshbricel/claude-omni-skills

The Problem: Column Names Lie

To demonstrate this, I built a sample Salesforce dataset,9,550 records across 6 tables (Users, Accounts, Contacts, Opportunities, Activities, Campaign Members) modeled as a SaaS company's CRM, loaded into Snowflake and connected to Omni. The data looks normal until you try to query it with AI.

Here are some types of fields that could break AI chatbots:

Field What It Sounds Like What It Actually Means
Competitor__c Competitor name For won deals: win attribution channel. For lost: competitor who won. For open: NULL.
IsClosed Deal is closed/won TRUE for both won AND lost. Must pair with IsWon.
WhatId ??? Polymorphic lookup,usually an Opportunity ID, but could be anything.
FiscalYear Calendar year July-start fiscal year (FY25 = Jul 2024 – Jun 2025).
LeadSource Marketing channel Original lead source only. Campaign attribution lives in SF_Campaign_Members.

None of this is discoverable from the schema. The DDL won't tell you that "Channel Wins" means WHERE Competitor__c LIKE 'CH%' AND StageName = 'Closed Won'. It won't tell you that win rate should be COUNT(IsWon=TRUE) / COUNT(IsClosed=TRUE) instead of COUNT(StageName='Closed Won') / COUNT(*). And it definitely won't tell you that FY25 starts in July 2024.

This is what context engineering is for,embedding business logic into the semantic layer so the AI gets it right on the first try.

The Solution: Build a Claude Code Skill for Semantic Layer Management

I built two scripts that form a complete deployment pipeline for Omni's semantic layer, packaged as a Claude Code skill:

  1. build_semantic_layer.py,Deploys view YAML, relationships, topics, and AI context to an Omni branch via the YAML API
  2. validate_omni_model.py,Runs 6 pre-merge validation checks that catch bugs Omni's API silently accepts

The key insight: Omni's YAML API accepts invalid configurations without error. Your deploy succeeds, your branch looks fine, but fields render without formatting, views are silently excluded from topics, and the AI chatbot can't find data it should be able to reach. These are the kinds of bugs that pass code review because the YAML "looks right."

Deploying YAML to a Branch

The build script uses Omni's POST /api/v1/models/{modelId}/yaml endpoint to write each file to a branch:

def post_yaml(file_name: str, yaml_content: str):
    """Write a YAML file to the branch."""
    url = f"{BASE_URL}/api/v1/models/{MODEL_ID}/yaml?branchId={BRANCH_ID}"
    resp = requests.post(url, headers=HEADERS, json={
        "fileName": file_name,
        "yaml": yaml_content,
        "mode": "combined",
    })
    if resp.status_code == 200:
        print(f"  OK: {file_name}")
    else:
        print(f"  FAIL: {file_name} -> {resp.status_code}: {resp.text}")
        return False
    return True

Each view file includes field-level descriptions, synonyms, and ai_context that teach the AI what the field actually means:

dimensions:
  competitor_c:
    sql: '"Competitor__c"'
    label: "Win Channel / Competitor"
    description: >
      MISLEADING NAME: For WON deals, stores win attribution
      channel (CH - xxx). For LOST deals, stores the competitor
      who won. NULL for open deals.
    synonyms:
      - win channel
      - competitor
      - channel wins
      - win source

  is_closed:
    sql: '"IsClosed"'
    label: "Is Closed"
    description: >
      TRUE for both Closed Won AND Closed Lost.
      Use with Is Won to distinguish wins from losses.

The topic file bundles all the business rules into a single ai_context block that the AI reads before generating any query:

ai_context: |
  CRITICAL FIELD NOTES:
  - Competitor__c is MISLEADINGLY named. For WON deals it stores
    the win attribution channel (values starting with 'CH -' like
    'CH - Partner Referral'). For LOST deals it stores the
    competitor who won. For open deals it is NULL.
  - "Channel Wins" means: Competitor__c LIKE 'CH%'
    AND StageName = 'Closed Won'
  - IsClosed is TRUE for BOTH won AND lost deals.
    Always pair with IsWon.
  - FiscalYear uses July start. FY25 = Jul 2024 through Jun 2025.
  - Win Rate = COUNT(IsWon=TRUE) / COUNT(IsClosed=TRUE),
    not divided by all deals.

The Hard Part: Catching Silent Failures

After deploying the initial semantic layer, I discovered three real bugs,none of which threw errors during deployment:

Issue What Happened Impact
format: usd Invalid format string,Omni requires usdcurrency 6 currency fields rendered without formatting
sf_contacts under sf_activities Topic declared a join path with no matching relationship Contacts silently excluded from AI queries
sf_campaign_members unreachable Invalid join graph meant campaign data was invisible Campaign attribution queries returned nothing

All three passed deployment without a single error message. I only found them when the AI Query Helper couldn't answer questions about contacts or campaigns.

The Validator: 6 Checks That Catch What Omni Doesn't

validate_omni_model.py runs these checks before any merge to production:

Check What It Does
1. Omni API Validation Calls the remote validate endpoint for server-side errors
2. Format Strings Validates against Omni's actual format spec (usdcurrency, percent, not usd)
3. Aggregate Types Validates aggregate_type values (sum, count, count_distinct)
4. Topic Join Graph Parses nested joins: structure and verifies each join has a matching relationship
5. View Reachability BFS from base_view through the join graph,finds unreachable views
6. Relationship Field Refs Extracts ${view.field} from on_sql and verifies each field exists

Here's what the validator output looked like before I fixed the bugs:

OMNI MODEL VALIDATION
============================================================

1. OMNI API VALIDATION
   PASS: 0 errors from Omni validator
   WARN: Invalid format string "usd" for field "annualrevenue"
   WARN: Invalid format string "usd" for field "amount"

2. FORMAT STRINGS
   FAIL: sf_accounts > annualrevenue: "usd" is not valid. Use "usdcurrency"
   FAIL: sf_opportunities > amount: "usd" is not valid. Use "usdcurrency"
   ... (6 total)

4. TOPIC JOIN GRAPH
   FAIL: Topic declares sf_contacts joined via sf_activities,
         but no relationship exists

5. VIEW REACHABILITY
   FAIL: sf_campaign_members is not reachable from base_view sf_opportunities
   FAIL: sf_contacts is not reachable from base_view sf_opportunities

Results: 3 passed, 9 failed, 8 warnings

After fixing: Results: 7 passed, 0 failed, 0 warnings

Valid Omni Format Strings (Reference)

This list was built through trial and error. I expect it'll keep growing:

Category Valid Formats
Numeric number, percent, id, billions, millions, thousands, big
Currency (standard) currency, usdcurrency, eurcurrency, gbpcurrency, audcurrency
Currency (accounting) accounting, usdaccounting, euraccounting, gbpaccounting, audaccounting
Currency (big) bigcurrency, bigusdcurrency, bigeurcurrency, biggbpcurrency
Precision suffix Any of the above + _N (e.g., usdcurrency_0, percent_2)

Common mistakes: usd → use usdcurrency. dollar → use usdcurrency. pct → use percent.

The Deployment Workflow: Branch-First, Always

Nothing deploys directly to production. Every change follows this workflow:

1. Deploy to branch     ->  python3 build_semantic_layer.py
2. Validate             ->  python3 validate_omni_model.py
3. Review in Omni UI    ->  Test AI chatbot with sample questions
4. Merge to production  ->  python3 validate_omni_model.py --merge

Key design decisions:

  • build_semantic_layer.py deploys to the branch by default. The --merge flag is required to push to production, and it re-validates first.
  • validate_omni_model.py --merge is the only way to merge. It re-runs all 6 checks and blocks if anything fails.
  • Exit code 1 on any failure,scripts can be chained in CI.

The Feedback Loop: AI Finds Gaps, You Fix the Model

The real payoff came when I started using the semantic layer with Omni's AI Query Helper. I asked:

"Create a dashboard that highlights my top 5 sales reps and shows the activities they've been taking within 30 days of opportunities being created."

The AI nailed the first chart,top 5 reps by won revenue. But the activity chart returned only 2 results. Almost nothing.

Why: The topic joined sf_activities directly to sf_opportunities via WhatId (a polymorphic FK). Most activities aren't logged against specific opportunities,they're logged against accounts. The WhatId join was technically correct but practically useless for this question.

The fix was a topic-level relationship change:

# Before: Activities joined directly to Opportunities via WhatId
joins:
  sf_accounts:
    sf_contacts:
      sf_campaign_members: 
  sf_activities:     # Direct join,misses 95% of activities
  sf_users: 

# After: Activities joined through Accounts
joins:
  sf_accounts:
    sf_contacts:
      sf_campaign_members: 
    sf_activities:    # Now through account name,catches all
  sf_users: 

After deploying the fix (to a branch, validating, and merging), the same dashboard prompt returned rich activity data across all 5 reps.

This is the iteration loop that makes the semantic layer worth building:

  1. Use the AI → discover what questions people actually ask
  2. Find gaps → the AI surfaces modeling problems you didn't anticipate
  3. Fix the model → update the YAML, validate, merge
  4. Better AI → future queries automatically use the improved model

The semantic layer isn't a one-time setup. It evolves.

Before and After: The Same Questions, Different Answers

Question Without Semantic Layer With Semantic Layer
"How many deals from referral partners in FY25?" Filters on LeadSource = 'Partner Referral' Filters on Competitor__c LIKE 'CH - Partner%' AND StageName = 'Closed Won' AND FiscalYear = 2025
"What's our win rate?" Closed Won / COUNT(*) COUNT(IsWon) / COUNT(IsClosed)
"Which reps have the most client meetings?" Doesn't know what counts as a "meeting" Filters Type IN ('Meeting', 'Site Visit', 'Product Demo')
"What campaigns drove the most revenue?" Joins Campaign directly to Opportunities (no FK) Joins Campaign Members → Contacts → Accounts → Opportunities
"Show me Q2 pipeline" Uses calendar Q2 (Apr–Jun) Uses fiscal Q2 (Oct–Dec)

Setting It Up: The .claude/CLAUDE.md Pattern

One pattern worth highlighting: how I handle secrets for Claude Code without leaking them to GitHub.

The .claude/CLAUDE.md file sits in the repo directory, so Claude Code auto-loads it as project context. But .gitignore excludes the entire .claude/ directory. Result: Claude Code always has API keys and model IDs in context, but the public repo has zero secrets.

# .claude/CLAUDE.md (gitignored, auto-loaded by Claude Code)

## Omni Environment Variables
export OMNI_BASE_URL="https://yourcompany.omniapp.co"
export OMNI_API_KEY="omni_osk_your_actual_key"
export OMNI_MODEL_ID="your-model-uuid"
export OMNI_BRANCH_ID="your-branch-uuid"

No .env file to source manually. No setup step to forget. Claude Code reads the context file automatically on every session.

Try It Yourself

The full skill is open source: github.com/joshbricel/claude-omni-skills

claude-skills/
├── skills/
│   ├── omni-branch-creator/
│   │   └── SKILL.md
│   └── omni-semantic-layer-setup/
│       ├── SKILL.md
│       └── scripts/
│           ├── .env.example
│           ├── build_semantic_layer.py
│           ├── validate_omni_model.py
│           └── validate_semantic_layer.py

To get started:

  1. Clone the repo
  2. Copy scripts/.env.example to scripts/.env and fill in your Omni credentials
  3. Create a branch with the branch creator skill (or manually in Omni)
  4. Run python3 build_semantic_layer.py to deploy your semantic layer
  5. Run python3 validate_omni_model.py to validate
  6. Test in the Omni UI
  7. Run python3 validate_omni_model.py --merge to go live

Key Takeaways

  1. Column names lie. Competitor__c has nothing to do with competitors for won deals,it stores the win attribution channel. Without a semantic layer, every AI query on misleading fields will be wrong.
  2. Business logic lives outside the schema. Fiscal years, win source patterns, polymorphic lookups,none of this is discoverable from DDL alone.
  3. Validate before you merge. Format strings, join paths, and view reachability can all silently break. My validator caught 3 real bugs that would have shipped to production.
  4. Branch-first deployment is non-negotiable. Deploying directly to production means users see broken models before you do. Branch → validate → review → merge.
  5. The semantic layer evolves. Your first deployment won't be your last. The AI chatbot itself is the best tool for finding gaps,use it, find what breaks, fix the model, repeat.
  6. You don't need big data to prove this. 9,550 records with realistic complexity is enough to demonstrate the value of context engineering. The gotcha fields are what matter, not the row count.

Thanks for reading,

Josh