npx skills add ...
npx skills add microsoft/dataverse-skills --skill dv-metadata
npx skills add microsoft/dataverse-skills --skill dv-metadata
Dataverse schema authoring and inspection — tables, columns, relationships, forms, and views. Use when the user wants to define, evolve, or inspect the data model — add a column, create a table, set up a lookup, customize a form, build a view, or list existing columns and relationships.
Before the first metadata change in a session:
SOLUTION_NAME is in .env, confirm it. If no solution exists yet, you MUST ask the user for the solution name and publisher prefix before creating anything. The publisher prefix is permanent — it cannot be changed after components are created with it.STOP and ask the user:
"What solution name and publisher prefix should I use? The prefix (e.g.,
contoso,lit,soc) is permanent on every table and column."
Then query existing publishers and show them — the user may want to reuse one:
After user confirms, create using SDK:
Never create tables or columns outside a solution.
solution="<UniqueName>" in every SDK call, or include "MSCRM.SolutionName": "<UniqueName>" on every raw Web API call.| Need | Use instead |
|---|---|
| Create, update, or delete data records | dv-data |
| Query or read records | dv-query |
| Export or deploy solutions | dv-solution |
Do not write solution XML by hand to create new tables, columns, forms, or views.
The environment validates metadata far more reliably than an agent editing XML. The correct workflow is:
pac commands where available)pac solution export + pac solution unpackThe exported XML is generated by Dataverse itself and is always valid. Hand-written XML is fragile — a single incorrect attribute or missing element causes an import failure with an opaque error.
The only time you write files directly is when editing something that already exists in the repo (e.g., tweaking an existing view's columns or modifying a form layout you've already pulled).
If creating multiple tables for a data import, also see these sections later in this skill:
Prefer the SDK for table creation — use raw Web API only when you need full control over OwnershipType, HasActivities, or other advanced properties the SDK doesn't expose.
SDK approach (use this by default):
Web API fallback (ONLY when you need OwnershipType, HasActivities, or other properties the SDK doesn't expose):
*Id Suffix CollisionsNever name a regular column with an Id suffix (e.g., prefix_CountryId). Dataverse auto-generates a navigation property with the Id suffix when you create a lookup — if a regular column with that name exists, lookup creation fails with a schema name collision.
prefix_DepartmentId (int) — collides with auto-generated lookupprefix_SrcDepartmentId or prefix_DepartmentSourceIdSDK approach (preferred):
Supported type strings: "string" / "text", "int" / "integer", "decimal" / "money", "float" / "double", "datetime" / "date", "bool" / "boolean", "file", and Enum subclasses (for local option sets).
Choice (picklist) column via SDK:
Web API approach (needed for column types the SDK doesn't support — e.g., currency with precision, memo with custom max length):
SDK approach — simple lookup (preferred):
SDK approach — full control over 1:N relationship:
SDK approach — many-to-many relationship:
Web API approach (fallback when SDK patterns don't suffice):
After creating a lookup — the @odata.bind navigation property:
When you create records that set this lookup, you need the navigation property name for @odata.bind. The navigation property name is case-sensitive and must match the entity's $metadata (usually the SchemaName of the lookup field, e.g., new_AccountId):
| Navigation Property Name | @odata.bind key | Entity set |
|---|---|---|
new_AccountId | new_AccountId@odata.bind | /accounts(<guid>) |
new_ParentTicketId | new_ParentTicketId@odata.bind | /new_tickets(<guid>) |
Common mistake: Using the logical name (lowercase) like new_accountid@odata.bind returns a 400 error. Navigation property names are case-sensitive and must match the entity's $metadata.
After creating a table via API, add it to your solution so it gets pulled on export:
Component type 1 = Entity (Table). See dv-solution for the full type code list.
Or via Web API:
systemform (forms) and savedquery (views) are ordinary entities — create/read/modify them with the SDK's record CRUD (no urllib). Only publishing (PublishXml, an unbound action) needs dataverse api request.
Quick reference:
client.records.create("systemform", {...formxml..., "type": 2}) (2=Main, 7=Quick Create, 6=Quick View, 11=Card).records.list("systemform", filter=...) for a template → mutate formxml → records.update("systemform", id, {...}) → publish.dataverse api request POST PublishXml — required for changes to take effect.client.records.create("savedquery", {...fetchxml..., ...layoutxml...}) (0=standard, 1=advanced find, 2=associated, 4=quick find).Full code, the template recipe, the classid table, and publish: references/forms-and-views.md.
Key invariants:
id attributes in form XML must be unique GUIDs (str(uuid.uuid4()).upper()).python -c for GUID generation on Windows — write a .py file.Create business rules in the Power Apps maker portal. They are too complex to write reliably as JSON/XAML. After creation, export+unpack the solution and commit the result.
All custom schema names must use your solution's publisher prefix (e.g., new_, contoso_). Find yours:
Or check solutions/<SOLUTION_NAME>/Other/Solution.xml after the first pull — look for <CustomizationPrefix>.
id attributes must be valid GUIDs in {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} format. Do not use strings like "general".labelid is also a GUID — not a human-readable string.<ViewId> — must be the GUID of an existing SavedQuery. Create the view first.classid values — see the classid table above.Tip: Create forms in the maker portal and pull via pac solution export — use the pulled XML as a template for programmatic creation.
After creating columns (via Web API or MCP), always report the actual logical names to the user. Column names may be normalized or prefixed in ways the user doesn't expect. Summarize in a table:
| Display Name | Logical Name | Type |
|---|---|---|
| cr9ac_email | String | |
| Tier | cr9ac_tier | Picklist |
| Customer | cr9ac_customerid | Lookup |
This prevents downstream failures when the user tries to insert data using incorrect column names.
| Error Code | Meaning | Recovery |
|---|---|---|
0x80040216 | Transient metadata cache error. Column or table metadata not yet propagated. | Wait 3-5 seconds and retry. Usually succeeds on second attempt. |
0x80048d19 | Invalid property in payload. A field name doesn't match any column on the table. | Check logical column names — use EntityDefinitions(LogicalName='...')/Attributes to verify. |
0x80040237 | Schema name already exists. | Verify the column/table exists before creating a new one — it may have been created by a previous timed-out call. |
0x8004431a | Publisher prefix mismatch. | Ensure all schema names use the solution's publisher prefix. |
0x80060891 | Metadata cache not ready after table creation. | Call GET EntityDefinitions(LogicalName='...') first to force cache refresh, then retry. |
Always translate error codes to plain English before presenting them to the user.
After creating tables / columns / alternate keys, Dataverse runs internal metadata operations (index build, cache propagation) for 3–30 seconds. Submitting another metadata operation while these run causes lock-contention errors.
Mitigation — phased creation, not interleaved. Create ALL tables → wait 15–30s → create ALL alternate keys → wait 15–30s → create ALL lookups. Do NOT interleave operations on the same table.
Symptoms (any of these means propagation isn't done):
0x80040216@odata.bind fails with "Invalid property"update_table (MCP) fails with "EntityId not found in MetadataCache"For the retry_metadata helper that catches transient lock errors and the full phased-creation sequence, see references/metadata-propagation.md.
Before changing a model, inspect what already exists. These read-only calls return raw metadata dictionaries (PascalCase property names) and are safe to run anytime.
Assumes
clientfrom the auth setup shown earlier in this skill (from auth import get_client).
For SQL-queryable column discovery (virtual/computed columns excluded), dv-query also has
client.query.sql_columns(table).
After every metadata session, perform the pull-to-repo sequence — see dv-overview "After Any Change: Pull to Repo" for the full export/unpack/commit commands.
If you used the MSCRM.SolutionName header during creation, verify components were added before exporting:
When creating tables programmatically (e.g., a schema setup script that may be re-run), use a check-first pattern — query client.tables.get() before creating. This is explicit, avoids masking unrelated errors, and lets you branch logic based on whether the table was created or reused:
UpsertMultiple requires an alternate key on the column(s) Dataverse should use to identify existing records. Always create alternate keys on source-system ID columns (prefix_Src*Id) at schema-setup time so every import is idempotent.
Quick reference:
client.tables.create_alternate_key(table, key_name, [columns], display_name=...). Composite keys: pass multiple columns.client.tables.get_alternate_keys(table) to skip keys that already exist — see references/alternate-keys.md for the ensure_alternate_key helper.client.tables.get_alternate_keys(table) until status == "Active" before using.For SDK code samples (single + composite + idempotent + status-check), the agent decision rules for which column to pick (DB source vs Excel/CSV), and the failure-handling notes, see references/alternate-keys.md.
startswith() is NOT supported as a filter on EntityDefinitions. This query will return a 400 error:
To retrieve metadata for multiple custom tables, query each table individually:
Or query all entities and filter in Python:
This matters for import scripts that need to discover entity set names (e.g., new_projectbudgets) before writing records with @odata.bind.
When using MCP create_table or update_table:
describe('tables/{name}') before retrying or a follow-up update_table (if the table exists, skip creation).update_table after creation.publisher_id = client.records.create("publisher", {
"uniquename": "<name>", "friendlyname": "<display>",
"customizationprefix": "<prefix>", # from user input, NOT hardcoded
"description": "<desc>",
})
solution_id = client.records.create("solution", {
"uniquename": "<SolutionName>", "friendlyname": "<Display Name>",
"version": "1.0.0.0",
"publisherid@odata.bind": f"/publishers({publisher_id})",
})import os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_client
# get_client sets a plugin attribution context on the User-Agent header.
# Do not modify the context value — it is a closed schema for server-side
# telemetry (app/skill/agent). Never include secrets or PII.
client = get_client("dv-metadata")
info = client.tables.create(
"new_ProjectBudget",
{"new_Amount": "decimal", "new_Description": "string"},
solution="MySolution",
primary_column="new_Name",
display_name="Project Budget", # human-readable name; plural auto-appends "s"
)
print(f"Created: {info['table_schema_name']}")# Helper for Label boilerplate
def label(text):
return {"@odata.type": "Microsoft.Dynamics.CRM.Label",
"LocalizedLabels": [{"@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel",
"Label": text, "LanguageCode": 1033}]}
entity = {
"@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata",
"SchemaName": "new_ProjectBudget",
"DisplayName": label("Project Budget"),
"DisplayCollectionName": label("Project Budgets"),
"Description": label(""),
"OwnershipType": "UserOwned",
"HasActivities": False, "HasNotes": False, "IsActivity": False,
"PrimaryNameAttribute": "new_name",
"Attributes": [{
"@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata",
"SchemaName": "new_name",
"DisplayName": label("Name"),
"RequiredLevel": {"Value": "ApplicationRequired"},
"MaxLength": 100, "IsPrimaryName": True,
}]
}
# POST to /api/data/v9.2/EntityDefinitions with MSCRM.SolutionUniqueName headercreated = client.tables.add_columns(
"new_ProjectBudget",
{"new_Description": "string", "new_Amount": "decimal", "new_Active": "bool"},
)
print(created) # ['new_Description', 'new_Amount', 'new_Active']from enum import IntEnum
class BudgetStatus(IntEnum):
DRAFT = 100000000
APPROVED = 100000001
REJECTED = 100000002
created = client.tables.add_columns(
"new_ProjectBudget",
{"new_Status": BudgetStatus},
)# Currency column
attribute = {
"@odata.type": "Microsoft.Dynamics.CRM.MoneyAttributeMetadata",
"SchemaName": "new_amount",
"DisplayName": {"@odata.type": "Microsoft.Dynamics.CRM.Label",
"LocalizedLabels": [{"@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel",
"Label": "Amount", "LanguageCode": 1033}]},
"RequiredLevel": {"Value": "None"},
"MinValue": 0,
"MaxValue": 1000000000,
"Precision": 2,
"PrecisionSource": 2
}
# POST to /api/data/v9.2/EntityDefinitions(LogicalName='new_projectbudget')/Attributesresult = client.tables.create_lookup_field(
referencing_table="new_projectbudget",
lookup_field_name="new_AccountId",
referenced_table="account",
display_name="Account",
solution="MySolution",
)
print(f"Created lookup: {result.lookup_schema_name}")from PowerPlatform.Dataverse.models.relationship import (
LookupAttributeMetadata,
OneToManyRelationshipMetadata,
CascadeConfiguration,
)
from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel
from PowerPlatform.Dataverse.common.constants import CASCADE_BEHAVIOR_REMOVE_LINK
lookup = LookupAttributeMetadata(
schema_name="new_AccountId",
display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]),
)
relationship = OneToManyRelationshipMetadata(
schema_name="account_new_projectbudget",
referenced_entity="account",
referencing_entity="new_projectbudget",
referenced_attribute="accountid",
cascade_configuration=CascadeConfiguration(delete=CASCADE_BEHAVIOR_REMOVE_LINK),
)
result = client.tables.create_one_to_many_relationship(lookup, relationship, solution="MySolution")
print(f"Created: {result.relationship_schema_name}")from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata
relationship = ManyToManyRelationshipMetadata(
schema_name="new_ticket_knowledgebase",
entity1_logical_name="new_ticket",
entity2_logical_name="new_knowledgebase",
)
result = client.tables.create_many_to_many_relationship(relationship, solution="MySolution")
print(f"Created: {result.relationship_schema_name}")relationship = {
"@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata",
"SchemaName": "account_new_projectbudget",
"ReferencedEntity": "account",
"ReferencingEntity": "new_projectbudget",
"Lookup": {
"@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata",
"SchemaName": "new_AccountId",
"DisplayName": {"@odata.type": "Microsoft.Dynamics.CRM.Label",
"LocalizedLabels": [{"@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel",
"Label": "Account", "LanguageCode": 1033}]},
"RequiredLevel": {"Value": "None"}
}
}
# POST to /api/data/v9.2/RelationshipDefinitionspac solution add-solution-component \
--solutionUniqueName <SOLUTION_NAME> \
--component <SchemaName> \
--componentType 1 \
--environment <url># POST to /api/data/v9.2/AddSolutionComponent
body = {
"ComponentId": "<entity-metadata-id>",
"ComponentType": 1, # 1 = Entity
"SolutionUniqueName": "<SOLUTION_NAME>",
"AddRequiredComponents": True
}pac solution list --environment <url># Columns on a table (optionally filtered / projected)
columns = client.tables.list_columns("account", select=["LogicalName", "AttributeType", "SchemaName"])
for col in columns:
print(f"{col['LogicalName']} ({col.get('AttributeType')})")
# All relationships for one table (1:N, N:1, and N:N combined)
rels = client.tables.list_table_relationships("account")
for rel in rels:
print(f"{rel['SchemaName']} -> {rel.get('@odata.type')}")
# All relationships in the environment (optionally filtered)
all_rels = client.tables.list_relationships(select=["SchemaName", "ReferencedEntity", "ReferencingEntity"])sol = client.records.list("solution",
filter="uniquename eq '<SOLUTION_NAME>'", select=["solutionid"], top=1).first()
if sol is not None:
components = client.records.list("solutioncomponent",
filter=f"_solutionid_value eq {sol['solutionid']}",
select=["componenttype", "objectid"])
print(f"{len(components)} components in the solution")def ensure_table(client, schema_name, columns, solution, primary_column="prefix_Name", display_name=None):
existing = client.tables.get(schema_name)
if existing:
print(f"Reusing: {schema_name}")
return existing
info = client.tables.create(schema_name, columns, solution=solution,
primary_column=primary_column, display_name=display_name)
print(f"Created: {info['table_schema_name']}")
return infoGET /api/data/v9.2/EntityDefinitions?$filter=startswith(LogicalName,'new_') # BROKENGET /api/data/v9.2/EntityDefinitions(LogicalName='new_projectbudget')?$select=LogicalName,EntitySetNameGET /api/data/v9.2/EntityDefinitions?$select=LogicalName,EntitySetName
# Then filter: [e for e in result["value"] if e["LogicalName"].startswith("new_")]