Adds a skill for authoring Tableau Next semantic models (SDMs) on Data 360: build from scratch, add data objects, define joins, enrich with calculated fields and metrics, and make models AI-ready. Smoke-tested against a live Data 360 org: SDM discovery, AI-readiness flip, dimension creation, metric creation, and description backfill all exercised end-to-end.
19 KiB
SDM AI-Readiness API Reference
REST endpoints + payload shapes for the last mile of an SDM: making it
AI-ready (flip agentEnabled, write a businessPreferences context block,
set description + categories) and updating an existing metric safely
(full-payload PUT so identifyingDimension / insightsSettings survive). This
is the contract the update scripts (update_sdm.py, update_metric.py) build
against. All shapes are verified against the v66.0 semantic REST API; the example
request/response status is shown per endpoint.
Base URL & conventions
https://{instance}.salesforce.com/services/data/v66.0
- No
minorVersion. Semantic endpoints (/ssot/semantic/...) omit theminorVersionquery param (matchessdm-creation-api.md). - Bearer auth:
Authorization: Bearer {access_token},Content-Type: application/json. - Bodies are camelCase.
agentEnabled= expose-to-AI (the metric/model is queryable by the agent);isAiDrafted= provenance (the model was drafted by AI). Distinct fields — never conflated. Making a model AI-ready setsagentEnabled, notisAiDrafted.
1. Model-level update (AI-readiness) — PATCH
PATCH /services/data/v66.0/ssot/semantic/models/{sdm} → 200
A partial update: send only the fields you want to change. The server merges them onto the existing model and echoes the full updated model back. Verb confirmed PATCH (not PUT) — a partial body is the normal, supported case (unlike metric update, §3, which is full-payload PUT).
Accepted allowlist (each field is settable via the REST PATCH):
app, sourceCreation, isAiDrafted, queryUnrelatedDataObjects, businessPreferences, description, agentEnabled, currency, label, categories.
Request (happy path, HTTP 200):
{
"agentEnabled": true,
"description": "Workforce model: headcount, hires, leavers by org and department.",
"businessPreferences": "PURPOSE: Track workforce headcount.\nGRAIN & JOINS: employee joined to calendar.\nKEY DEFINITIONS: Headcount = active employees at period end.",
"categories": []
}
Response (200, abbreviated) — note the server HTML-encodes the
businessPreferences body on the way in (& → &):
{
"agentEnabled": true,
"apiName": "Workforce_SDM",
"businessPreferences": "PURPOSE: Track workforce headcount.\nGRAIN & JOINS: employee joined to calendar.\nKEY DEFINITIONS: Headcount = active employees at period end.",
"categories": [],
"currency": { "useOrgDefault": true },
"dataspace": "default",
"description": "Workforce model: headcount, hires, leavers by org and department."
}
categoriesis a JSON array of controlled values — NOT free-form text. The server validates each entry against a "Semantic Category" enum and rejects unknown values: a PATCH with["HR", "People"]returns HTTP 400POST_BODY_PARSE_ERROR: Invalid value for Invalid Semantic Category: HR. Send[](the common case) or only known category values; a comma string is the CLI's job to split into a list, but the values themselves must be valid.currencycomes back as{"useOrgDefault": true}(object), not a scalar.- Idempotent: re-sending the same fields produces the same stored state.
2. description length cap — 255, measured on the RAW input
The server caps description at 255 characters, measured on the raw (decoded)
input string — NOT on the HTML-encoded length. The server does HTML-encode
the value before storing it (so the stored string can exceed 255 once & →
& etc. inflate it), but the 255 limit is checked against what you send.
Two cases settle this:
Case A — 94 raw chars / 298 HTML-encoded chars → ACCEPTED (HTTP 200).
A description of 94 raw characters containing 51 & signs encodes to 298
characters and is accepted; the stored value is the 298-char encoded form
(...&&...). If the cap were on the encoded length, 298 > 255 would be
rejected. It isn't → the cap is not on the encoded length.
Case B — 256 raw plain-ASCII chars → REJECTED (HTTP 400).
HTTP 400
"Error while updating semantic object: The update of semantic entity with API
name ({sdm}) failed. caused by: Description: data value too large:
AAAA...AAAA (max length=255)"
256 plain-ASCII chars (no special chars, so raw == encoded == 256) is rejected
with max length=255. → the cap is 255 on the raw input length.
Guidance for the client-side guard: reject a description when
len(description) > 255 (raw length). Do not HTML-encode before measuring —
that would over-reject legitimate &-containing descriptions that the server
accepts. When rejecting, point the user to put depth in businessPreferences
(which takes multi-paragraph blocks with no limit; see §2a).
2a. businessPreferences length
businessPreferences accepts a multi-paragraph block (the 6-heading template —
hundreds of characters) without error; there is no observed length limit. The
field is the right home for the context depth that does not fit in the
255-char description. Like description, the server HTML-encodes the stored
value.
3. Metric update — full-payload PUT
PUT /services/data/v66.0/ssot/semantic/models/{sdm}/metrics/{metric} → 200
Metric update is a full-payload PUT (not PATCH). The PUT body replaces the metric definition. Any field you omit is dropped / reset — there is no server-side merge. So a single-field change must re-send the complete definition.
Required re-sent fields
Always re-send, on every update, the full metric definition — at minimum:
label, measurementReference, timeDimensionReference, insightsSettings
(plus apiName, aggregationType, timeGrains, additionalDimensions,
filters, isCumulative, isGoalEditingBlocked). Strip server read-only
fields before PUT: id, createdBy, createdDate, lastModifiedBy, lastModifiedDate, url.
The safe pattern (what update_metric.py does): GET the metric's full
definition → overlay the requested change → strip read-only fields → PUT the
complete body.
Full PUT happy path (HTTP 200)
GET .../metrics/{metric} returns the full definition; re-PUT it with one changed
field (description) and everything else re-sent → HTTP 200; re-resolving the
metric confirms insightsSettings and all additionalDimensions are retained.
The regression a partial PUT causes
A partial PUT (only apiName + label + description +
measurementReference + timeDimensionReference + timeGrains, omitting
insightsSettings and additionalDimensions) returns HTTP 200 but causes
data loss, visible when you re-resolve the metric:
| field | before | after partial PUT |
|---|---|---|
additionalDimensions |
N entries | 0 entries |
insightsSettings.insightsDimensionsReferences |
N | re-derived |
additionalDimensions backing the identifying dim |
present | gone |
Dropping additionalDimensions orphans the identifyingDimension (which must
reference an additional dimension — see the metric-create superset rule). This
is the regression the full resolve-and-merge PUT prevents: a single-field change
made naively (partial body) silently strips the breakdown dimensions and the
identifying dimension that make the metric usable in the Tableau Next UI.
identifyingDimension shape
insightsSettings.identifyingDimension carries an
identifierDimensionReference, which is a normal dimension reference:
"insightsSettings": {
"identifyingDimension": {
"identifierDimensionReference": {
"tableFieldReference": { "fieldApiName": "Date", "tableApiName": "Daily_Property_Performance" }
}
}
}
- Same-object reference:
tableFieldReference.{fieldApiName, tableApiName}. - Cross-object reference: the CLI accepts
Field:Objectand emits the sametableFieldReferencewithtableApiName= the other object's apiName (a metric can identify on a dimension from a joined object). - The identifying field MUST also appear in top-level
additionalDimensions[](the create-time superset rule applies to updates too).
Time-comparison settings
primaryTimeComparison / secondaryTimeComparison are top-level optional
fields on the metric (siblings of timeGrains), not nested in insightsSettings.
They default to unset (null); when the CLI sets them they ride along in the full
PUT body like any other field.
Full metric definition (resolved shape)
{
"apiName": "Headcount_mtc",
"label": "Headcount",
"aggregationType": "UserAgg",
"description": "Number of active employees at the last date of the selected period",
"measurementReference": { "calculatedFieldApiName": "Headcount_clc" },
"timeDimensionReference": {
"tableFieldReference": { "fieldApiName": "report_date", "tableApiName": "qb_hw_calendar" }
},
"timeGrains": ["Day", "Week", "Month", "Quarter", "Year"],
"additionalDimensions": [
{ "tableFieldReference": { "fieldApiName": "position_title", "tableApiName": "qb_hw_position" } }
],
"filters": [],
"isCumulative": false,
"isGoalEditingBlocked": false,
"insightsSettings": {
"insightTypes": [ /* ... */ ],
"insightsDimensionsReferences": [ /* mirrors additionalDimensions */ ],
"pluralNoun": "", "singularNoun": "", "sentiment": "SentimentTypeUpIsGood"
}
}
(Server read-only fields id/createdBy/createdDate/lastModifiedBy/
lastModifiedDate are present on GET and must be stripped before PUT.)
4. Verify a calc field in a semantic query — semanticField, NOT calculatedField
POST /services/data/v66.0/semantic-engine/gateway → 201, status: "SUCCESS"
To reference an existing SDM calculated field (_clc) in a semantic query,
use the semanticField {name} expression shape (REST camelCase:
semanticField). The calculatedField {name} shape is for an on-the-fly
formula (it expects an inline expression); pointing it at an existing calc
field's name fails.
WRONG (calculatedField by name) → HTTP 400 INVALID_API_INPUT:
{ "semanticModelApiName": "Workforce_SDM",
"structuredSemanticQuery": {
"fields": [ { "expression": { "calculatedField": { "name": "Headcount_clc" } }, "alias": "v" } ],
"options": { "limitOptions": { "limit": 3 } } } }
HTTP 400
[{ "errorCode": "INVALID_API_INPUT",
"message": "...QUERY_PREPARE|INVALID_ARGUMENT: Expression cannot be null or empty" }]
CORRECT (semanticField by name) → HTTP 201, SUCCESS:
{ "semanticModelApiName": "Workforce_SDM",
"structuredSemanticQuery": {
"fields": [ { "expression": { "semanticField": { "name": "Headcount_clc" } }, "alias": "v" } ],
"options": { "limitOptions": { "limit": 3 } } } }
{ "status": "SUCCESS",
"queryResults": { "queryData": { "rows": [ { "values": [2172.0] } ] } } }
(A text-based query DSL spells the same distinction in snake_case —
semantic_field / calculated_field; the REST gateway uses the camelCase
semanticField / calculatedField keys.)
5. Update a dimension / measurement / data object (description/label) — full-payload PUT
PUT .../data-objects/{obj}/dimensions|measurements/{field} (base field)
PUT .../data-objects/{obj} (data object)
→ 200 in both cases.
A raw base field's or a data object's description (and label) can be updated
after create — the sub-resource takes a full-payload PUT. PATCH is
rejected at every level:
PATCH .../data-objects/{obj}/dimensions/{field} (or .../measurements/{field}, or .../data-objects/{obj})
HTTP 405
[{ "errorCode": "METHOD_NOT_ALLOWED",
"message": "HTTP Method 'PATCH' not allowed. Allowed are DELETE,GET,HEAD,PUT" }]
Because PUT replaces the resource, re-send the complete definition or the other fields get nulled. Same resolve-and-merge pattern as the metric update: GET the resource → overlay the change → PUT the full body.
Base field — GET (resolve) → PUT (changed description) → 200; the read-back
shows the new description with dataType / aggregationType /
dataObjectFieldName intact:
{ "apiName": "report_date", "label": "Report Date",
"dataObjectFieldName": "report_date__c", "dataType": "Date",
"displayCategory": "Discrete", "description": "The primary date field..." }
update_field.py --dimension|--measurement <field> --description <text> does this.
Data object — GET (resolve) → PUT (changed description) → 200; re-send the
full object body so dataObjectName / dataObjectType / tableType and the
nested semanticDimensions/semanticMeasurements arrays survive:
{ "apiName": "qb_hw_calendar", "label": "qb_hw_calendar",
"dataObjectName": "qb_hw_calendar__dlm", "dataObjectType": "Dmo",
"tableType": "Standard", "description": "A standard calendar table..." }
update_object.py <obj> --description <text> does this.
For all of these: strip the read-only id/createdBy/createdDate/
lastModifiedBy/lastModifiedDate (and, for the data object, the derived
semanticDimensionsUrl/semanticMeasurementsUrl) before PUT.
Endpoint summary
| Operation | Method | Path (no minorVersion) |
Status |
|---|---|---|---|
| Model-level update (AI-readiness) | PATCH | /ssot/semantic/models/{sdm} |
200 |
| Metric update (full-payload) | PUT | /ssot/semantic/models/{sdm}/metrics/{metric} |
200 |
| Read model (discovery) | GET | /ssot/semantic/models/{sdm} |
200 |
| Resolve one metric (full def) | GET | /ssot/semantic/models/{sdm}/metrics/{metric} |
200 |
| Resolve one base field | GET | /ssot/semantic/models/{sdm}/data-objects/{obj}/dimensions|measurements/{field} |
200 |
| Base-field update (full-payload) | PUT | /ssot/semantic/models/{sdm}/data-objects/{obj}/dimensions|measurements/{field} |
200 |
| Resolve one data object | GET | /ssot/semantic/models/{sdm}/data-objects/{obj} |
200 |
| Data-object update (full-payload) | PUT | /ssot/semantic/models/{sdm}/data-objects/{obj} |
200 |
| Verify field/metric query | POST | /semantic-engine/gateway |
201 (SUCCESS) |
Key landmines
descriptioncap = 255 on the RAW input. The server HTML-encodes for storage but measures the limit against what you send. Guard withlen(description) > 255(raw) — do NOT encode before measuring. Over-long → move depth intobusinessPreferences.- Metric update is full-payload PUT (not PATCH). Omitted fields are dropped. Always resolve-and-merge: GET full def → overlay change → PUT complete body.
identifyingDimensionmust survive the PUT (and must be a member ofadditionalDimensions). A naive partial PUT dropsadditionalDimensionsto empty, orphaning it — and the Tableau Next metric UI dereferencesidentifyingDimensionon load.- Dimension / measurement / data-object descriptions update via full-payload
PUT, not PATCH.
PATCHon the sub-resource returns 405 (Allowed are DELETE, GET, HEAD, PUT); aPUTof the complete definition (with the changeddescription/label) returns 200 — confirmed at all three levels (base dimension, base measurement, data object). Resolve first, then re-send the full body — see §5. - Reference existing calc fields via
semanticField {name}, notcalculatedField {name}(→INVALID_API_INPUT).
businessPreferences template
businessPreferences is the AI context block — there is no observed length limit (unlike description, which is capped at 255; see Common Errors), so put the depth here. Structure it with these six headings so the agent has the join graph, the metric semantics, the user's vocabulary, and the data caveats:
PURPOSE: What this model answers and for whom. The headline metrics.
GRAIN & JOINS: The grain of each object and how they join (the join graph) —
e.g. employee : calendar (snapshot), employee : position (ManyToOne).
KEY DEFINITIONS: Precise definitions of the metrics/terms — e.g.
"Headcount = count of active employees at the LAST date of the selected period."
SYNONYMS: The words users use mapped to model terms — e.g.
"workforce / staff / people = employees; attrition = turnover."
DATA CAVEATS: What to warn about — empty/partial sources, known gaps, date ranges
that have data, fields that look usable but aren't.
PREFERRED MEASURES: Which measure/metric to reach for by default for a given
question, so the agent doesn't pick a near-duplicate.
Update an existing metric (full-payload PUT) — commands
Metric update is a full-payload PUT — the body replaces the metric definition, so a single-field change must re-send the complete metric or the server silently drops insightsSettings / additionalDimensions / identifyingDimension. update_metric.py does this safely (resolve-and-merge: GET the full definition → overlay the change → PUT the complete body):
# Set the identifying dimension (the TN metric UI dereferences this on load)
python scripts/update_metric.py {{SDM_NAME}} {{Metric_mtc}} \
--identifying-dimension "position_title:qb_hw_position"
# Cross-object identifying dimension (Field:Object on a joined object)
python scripts/update_metric.py {{SDM_NAME}} {{Metric_mtc}} \
--identifying-dimension "Date:Daily_Property_Performance"
# Time comparisons
python scripts/update_metric.py {{SDM_NAME}} {{Metric_mtc}} \
--primary-comparison PriorPeriod --secondary-comparison PriorYear
# Dry-run: print the full PUT body without calling the org
python scripts/update_metric.py {{SDM_NAME}} {{Metric_mtc}} \
--identifying-dimension "gender:qb_hw_employee" --dry-run
--identifying-dimension takes Field:Object (fieldApiName:tableApiName); the field is mirrored into additionalDimensions if absent (the UI requires the identifying dimension to be a member).
Update a base field's or data object's description — commands
Base dimension/measurement and data-object descriptions are also updatable — via full-payload PUT (PATCH returns 405). update_field.py / update_object.py resolve the full definition, overlay the change, and PUT the complete body (so dataType/aggregationType and the object's nested field arrays survive):
# Update a dimension's description
python scripts/update_field.py {{SDM_NAME}} {{Object}} \
--dimension {{field}} --description "Business calendar date; the metric time anchor."
# Update a measurement's label + description
python scripts/update_field.py {{SDM_NAME}} {{Object}} \
--measurement {{field}} --label "Headcount" --description "Active employees at period end."
# Update a data object's description
python scripts/update_object.py {{SDM_NAME}} {{Object}} \
--description "Business calendar table; the time spine for all metrics."