Add dataverse
Skill microsoft/power-platform-skills/plugins/mobile-apps/skills/add-dataverse
Use when the user wants to add Dataverse tables (existing or new) to a Power Apps mobile app, extend an existing Dataverse table with new columns, or apply an approved data model plan.From its SKILL.md
npx -y skills add microsoft/power-platform-skills --skill add-dataverseAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- skips confirmationTells the agent to proceed without asking first, 4 times: "silently take Path B (Step 2.6 β spawn architect). No prompt." and 3 more.
- runs commandsInstructs the agent to run 8 commands, including `test -f power.config.json && test -f app.config.js` and 7 more.
SKILL.md
50.1 KB, ~12.5k tokens by cl100k_base, as published. Nobody here has run it
π Shared instructions: shared-instructions.md β read first.
Add Dataverse
Two paths:
- Existing tables only β skip to Step 5 (just runs
npx power-apps add-data-sourceper table) - New / extended tables β full workflow with Web API mutations in dependency order
Workflow
- Verify project & auth β 2. Resolve plan β 3. Setup Dataverse Web API auth β 4. Review existing tables β 5. Create / extend tables β 5d. Create alternate keys β 6. Add data sources β 6b. Publish customizations β 6c. Verify tables β 6d. Write manifest β 7. Inspect generated files β 8. Type-check β 8.5. Offline profile reconciliation β 9. Summary
Step 1 β Verify project & auth
Confirm Power Apps mobile app:
test -f power.config.json && test -f app.config.js
node "${CLAUDE_SKILL_DIR}/../../scripts/resolve-environment.js" "$(node -e \"console.log(require('./power.config.json').environmentId)\")"
Capture the environment URL (https://orgXXX.crm.dynamics.com), environment ID, and tenant ID from resolve-environment.js β needed for Step 3. If only the environment URL is available, pass that URL instead of the ID.
Step 2 β Resolve plan
Look for native-app-plan.md in the project root:
test -f native-app-plan.md
If present: read the ## Data Model section. Extract:
- The reuse / extend / create table
- The Mermaid ER diagram (informational)
- The "Creation Order" tier list
If absent: check $ARGUMENTS for diagram hints (*.png, *.jpg, *.jpeg filename, erDiagram keyword, ||--o{ cardinality syntax).
-
Diagram hint present β Path A (Step 2.5).
-
No hint AND
$ARGUMENTSdescribes what the app does (the typical case) β silently take Path B (Step 2.6 β spawn architect). No prompt. -
No hint AND
$ARGUMENTSis empty / non-descriptive β only then prompt withAskUserQuestion:"How would you like to define the data model? (a) I have an existing ER diagram to upload (PNG/JPG path, Mermaid syntax, or text description) (b) Let the data-model-architect agent analyze and propose one (default) (c) Cancel β I'll plan it elsewhere first"
Default the answer to (b) so empty/cancel input auto-proceeds. The 99% case (user gave a description but no diagram) skips this prompt entirely.
Step 2.5 β Path A: Parse user-provided diagram
Used when the user has an existing diagram from another tool (Visio, dbdiagram.io, screenshot, hand-drawn).
Accept three input formats:
| Format | How |
|---|---|
Image path (*.png / *.jpg / *.jpeg) | Use Read on the file path. The vision-capable model extracts entities, columns, relationships. |
| Mermaid syntax | User pastes a erDiagram block in chat. Parse the entities, columns, and ||--o{ cardinalities directly. |
| Text description | User types a structured description ("Account has many ServiceVisits; each ServiceVisit has many WorkItems and Photos"). Spawn data-model-architect agent in parse-only mode with the text as input. |
Whichever format, normalize into the same structure used by the planner agent:
publisherPrefix: <from detected publisher prefix or user>
tables:
- logicalName: contoso_servicevisit
displayName: Service Visit
status: new # new | extend | reuse
columns: [...]
relationships: [...]
Then:
- Query existing Dataverse (Step 4 logic) to mark each table as
new,modified, orreused. - Generate a Mermaid ER diagram from the parsed structure for visual confirmation.
- Present back to the user via
EnterPlanModefor approval. - On
ExitPlanMode, write the approved data model intonative-app-plan.md## Data Modelsection (creating the file if it doesn't exist). - Continue to Step 3.
Step 2.6 β Path B: Spawn architect agent
If the user picked Path B (or the user-provided diagram parse failed), spawn the mobile-app:data-model-architect agent via Task (the mobile-app: plugin-name prefix is required) with the user's high-level requirements as input. The agent returns _dm_section.md. Embed it in native-app-plan.md, present via EnterPlanMode for approval, then continue to Step 3.
If they need new tables and refuse both paths, recommend they run /setup-datamodel (alias of this skill) explicitly, or native-app-planner for a full app-level plan. STOP if neither.
Step 3 β Setup Dataverse Web API auth
Required only if creating or extending tables. Skip to Step 5 for read-only add-data-source.
Step 3a β Environment consistency check
npx power-apps and az authenticate independently β they can point to different accounts. Verify power.config.json resolves and az can token for the target tenant before making any Dataverse API calls:
ENV_JSON=$(node "${CLAUDE_SKILL_DIR}/../../scripts/resolve-environment.js" "$(node -e \"console.log(require('./power.config.json').environmentId)\")")
echo "$ENV_JSON"
az account show --query "{user: user.name, tenant: tenantId}" -o json
Compare the resolved environment URL with <envUrl> captured in Step 1. If they differ, STOP and warn:
"β οΈ Environment mismatch detected:
- resolver reports:
<resolved_env_url>- This project targets:
<envUrl>The Dataverse API token comes from
az, which must target the same tenant as the selected environment. Run:az login --tenant <tenant-id> # switch az to the right tenantThen re-run
/add-dataverse."
Do NOT proceed with table creation if environments don't match β you'll create tables in the wrong org.
Step 3b β Acquire token
az account show --query "user.name" -o tsv
If empty, instruct az login and stop.
Script invocation contract β read this once, all subsequent calls in this skill follow it:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> <METHOD> <apiPath> [--body '<json>'] [--include-headers]
- Three positional args, in order:
<envUrl>,<METHOD>(GET / POST / PATCH / DELETE),<apiPath>(everything after/api/data/v9.2/). - Body is a flag, not positional.
--body '<json>'β required for POST/PATCH, never for GET/DELETE. Forgetting--bodyand passing the JSON as a 4th positional arg returns a usage error. --include-headersadds response headers (needed forOData-EntityIdafter a record create).- Output is JSON:
{ "status": <code>, "data": <body> }. Token refresh on 401 and back-off on 429 are automatic β never wrap with manual retry.
Acquire a Dataverse access token and verify connectivity:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET WhoAmI
WhoAmI is the Dataverse identity endpoint β capital W/A/I (case-sensitive). The response gives UserId, BusinessUnitId, OrganizationId but does NOT include the publisher prefix. To get the publisher prefix, query the solution's publisher (defaults to Default; pass a different solution name if the env uses a custom solution):
node "${CLAUDE_SKILL_DIR}/../../scripts/detect-publisher-prefix.js" <envUrl> [solutionName]
# solutionName defaults to "Default" if omitted
This runs the OData query:
/api/data/v9.2/solutions?$select=uniquename&$expand=publisherid($select=customizationprefix)&$filter=uniquename eq '<solutionName>'
Capture customizationprefix from the solution's publisher (typical value: cr123 β schema names like cr123_jobsite). Also capture the solution uniquename β needed for the --solution flag on every Step 5 / 5b POST so artifacts land in our solution rather than landing wherever Dataverse defaults. Write both to memory-bank.md Power Platform context block.
Requires the user to hold System Administrator or System Customizer in this environment.
Step 4 β Review existing tables
Print before starting:
"β Querying existing custom tables in the environmentβ¦"
Always query before creating:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions?\$filter=IsCustomEntity eq true&\$select=SchemaName,LogicalName,DisplayName"
For each plan entry classified Reuse or Extend, fetch the table's columns to confirm the match:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions(LogicalName='<table>')/Attributes?\$select=LogicalName,AttributeType,RequiredLevel"
Schema divergence handling is in Step 5b's per-column pre-flight (not a Step 4 prompt). The pre-flight auto-skips columns that already exist with the same type, and STOPs only on incompatible type drift β no separate confirmation needed here.
Step 5 β Create / extend tables
Print before starting:
"β Creating/extending <N> tables in tier order (sequential β Dataverse serializes metadata writes). For each: pre-flight check, then 'Creating <table>β¦' before the POST and 'β <table>' on 2xx response."
β οΈ Concurrency rule β do not violate. All Dataverse metadata operations in Steps 5, 6, and 6b are strictly sequential: issue one HTTP request, wait for a 2xx response, then issue the next. Do NOT batch, parallelize, or fire concurrent requests. Dataverse serializes metadata writes via an exclusive lock; parallel calls return
429 TooManyRequests,MetadataLockHeldException, or404 EntityNotFoundfor lookups whose parent hasn't committed yet.Specifically:
- Within a tier: create tables one at a time.
- Across tiers: Tier 0 fully done (all tables + all columns committed) before any Tier 1 POST.
- Lookups: POST to
/RelationshipDefinitionsonly after both endpoint tables exist and have returned 2xx.- Extensions: column POSTs to an existing table are also serial β same lock applies.
Step 5a β Pre-flight collision check (per table, before each POST)
Step 4 listed known custom tables you intend to reuse. Step 5a probes for unknown problems on a per-create basis: name-prefix collisions from stale solutions, soft-deleted tombstones, and reserved system names. Skipping this check costs ~1 minute per failed POST (Dataverse takes its time returning the conflict error) and can leave Tier 0 partially created when a Tier 1 lookup fails on a phantom parent.
For every Create entry, before its POST, probe the target logical name:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions(LogicalName='<prefix>_<table>')?\$select=MetadataId,LogicalName,IsCustomEntity"
Branch on the response:
| Status / body | Meaning | Action |
|---|---|---|
| 404 NotFound | Name is free | Proceed with POST. |
200 OK + IsCustomEntity: true + MetadataId matches memory-bank | We created this earlier β idempotent re-run | Skip the POST, mark as created, continue. |
200 OK + IsCustomEntity: true + MetadataId not in memory-bank | Foreign collision | Auto-recover (see below) β do NOT prompt. |
200 OK + IsCustomEntity: false | Reserved system table name | Auto-recover via rename (see below). |
5xx with 0x80060890 or message "object with same name exists in solution" | Tombstone (soft-deleted, ~30 min purge TTL) | Auto-recover via rename (see below). |
400 with 0x80044363, "schema name ... is not unique", or "same name already exists" | Hidden Dataverse collision / recent-delete tombstone not visible to EntityDefinitions GET | Auto-recover via rename (see below), then retry the POST once. |
Important: Step 5a is a best-effort preflight, not the final authority. Dataverse can return 404 for a recently deleted table and still reject the create POST minutes later because the schema name remains reserved internally. Treat that POST-time 400 as a recoverable name collision, not a data-model failure.
Auto-recovery β reuse/extend first, rename as last resort
Priority order when Step 5a hits a name collision:
- Adopt as Extend (preferred) β if the existing table's
Attributesoverlap with the planned columns by β₯50%, or the existing table is the same conceptual entity: add only the missing columns via per-column POST (Step 5b Extend path). No prompt needed β extend automatically and logβ Extending existing <original> with <N> missing columns. - Adopt as Reuse β if the existing table's schema already covers all planned columns: skip Step 5b for this entry, keep it in Step 6 for service generation. No prompt. Log
β Reusing existing <original> (all required columns present). - Rename and Create (last resort) β only when the existing table is a fundamentally different entity (e.g., planned table is an inspection log but existing
<original>is a payroll record β incompatible concept, incompatible columns). Prompt the user before proceeding.
When to auto-decide vs. prompt:
| Situation | Action |
|---|---|
| Foreign collision + schema overlap β₯50% | Auto-Extend (no prompt) |
| Foreign collision + all planned columns present | Auto-Reuse (no prompt) |
| Foreign collision + incompatible concept | Prompt (see below) |
| Reserved system name | Auto-rename (no prompt) |
| Tombstone (0x80060890 / same-name-exists) | Auto-rename (no prompt) |
For the incompatible-concept case only β prompt via AskUserQuestion:
| Option | What it means |
|---|---|
| Extend existing (default) | Add required columns to <original>. Safer β avoids duplicate tables. |
| Rename and Create | Auto-renamed to <new>. Existing table left untouched. |
Default to "Extend existing" so an empty answer auto-proceeds. Rename-and-Create is the opt-in exception, not the default.
Maintain a run-level logical-name alias map for every auto-rename. Example:
{ "cr3e9_aircraft": "cr3e9_aircraftv2" }
Before building any later table, column, lookup relationship, sample-data payload, service-reference text, or screen data spec, resolve logical names through this map. A rename that only changes the table POST but leaves relationships/screens/sample data pointing at the old name is a bug.
Auto-rename probe sequence (cap at 4 probes β only used for reserved/tombstone cases):
<original>v2 β <original>v3 β <original>2 β <original>copy
For each candidate in order, GET EntityDefinitions(LogicalName='<candidate>')?$select=MetadataId,IsCustomEntity:
- 404 β free, take it, stop probing.
- 200 or 5xx (collision) β next candidate.
If all 4 probes collide, surface a BLOCKED: cannot find a free alternative for <original> and stop.
On a successful auto-rename, do these in order BEFORE the POST:
- Update
native-app-plan.mdβEditwithreplace_all: trueto swap the old logical name for the new one across the entire## Data Modelsection (Mermaid ER, Reuse/Extend/Create table, Creation Order, Notes). This catches downstream relationship POSTs in this same Step 5 too. - Update
## Screensper-screen specs β samereplace_allsweep for any service / data-source references using the old name. - Append to
memory-bank.mdCollision history β<original> β <new>with reason (foreign / reserved / tombstone) and timestamp. - Update the run-level alias map β every later metadata payload and plan edit resolves
<original>to<new>before use. - Inform the user β single line, no prompt:
β Collision on <original> (<foreign|reserved|tombstone>). Renamed to <new> and updated plan + memory-bank. Continuing.
Then proceed with the POST using <new>.
Post-create collision rescue β hidden tombstone / recent delete
If the Step 5b table POST fails after a 404 preflight with any Dataverse name-collision signature, do not fail the run:
- HTTP
400with code0x80044363 - message contains
schema nameandnot unique - message contains
same name already exists - message contains
object with same name exists in solution
First attempt auto-Extend: re-GET the existing table's attributes and compare with the plan. If β₯50% overlap, switch to Extend path (add missing columns). Otherwise, run the auto-rename probe sequence, update native-app-plan.md, ## Screens, memory-bank.md, and the run-level alias map, then retry the table POST exactly once with the resolved name. Print:
β Dataverse still has <original> reserved from a recent delete/hidden collision. Using <new> and continuing.
If the retry also returns a collision signature, continue probing the remaining candidates. If all candidates collide, return BLOCKED: cannot find a free alternative for <original>.
On successful POST, immediately re-GET to capture the server-assigned MetadataId and write it to memory-bank (Step 6d updates .datamodel-manifest.json; you also append to memory-bank.md under "Created tables" with the GUID and solution name). This lets future /add-dataverse runs distinguish "we own this" from "name collision."
Step 5b β Create / extend
For each Create decision, in tier order (Tier 0 β Tier 1 β Tier 2 β β¦), POST a new EntityDefinition. Skip if Step 5a returned a known-self match (idempotent).
β οΈ Inline ALL planned columns into the Create POST body β do NOT POST columns individually.
Dataverse processes the
Attributes: [...]array atomically with the table create. Inline form: 1 round trip, ~3-8s. Per-column form: N+1 round trips, each ~3-8s. For a 5-column table that's 24s saved per table on the lock-serialized metadata path.Wrong (N round trips):
{ "SchemaName": "...", "Attributes": [{ /* primary only */ }] } // then 4Γ POST /Attributes for the restRight (1 round trip):
{ "SchemaName": "...", "Attributes": [ { /* primary name */ }, { /* column 2 */ }, { /* column 3 */ }, { /* column 4 */ }, { /* column 5 */ } ] }The per-column POST path remains valid for two cases only: (1) Extend on an existing table, (2) retry-after-partial-failure when Step 5a's pre-flight shows the table exists but some columns don't.
Solution targeting (HARD): every Step 5 / 5b POST MUST pass --solution <uniquename> so Dataverse routes the new artifact into our solution rather than the unmanaged default. Read the solution name from memory-bank.md Power Platform context (captured in Step 3b). Without this flag, multi-project environments end up with cross-solution leakage and the foreign-collision class of bug returns. The script translates --solution to the MSCRM.SolutionUniqueName HTTP header.
Scratch files: When writing request body JSON to disk (e.g. table definitions, column metadata, relationship payloads), always write to <working_dir>/.tmp/, never to /tmp/. The hook validate-write-safety.js blocks writes outside the project directory. Create the folder if it doesn't exist: mkdir -p <working_dir>/.tmp.
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> POST EntityDefinitions \
--body '<json-body-with-all-columns-inline>' \
--solution '<solution-uniquename-from-memory-bank>'
Body skeleton β all planned columns inline in Attributes: [...] (this example shows primary + 3 additional; expand the array to fit every column from the plan):
β οΈ
IsAvailableOffline+ChangeTrackingEnabledMUST be set totrueat create time for any table the app intends to make available offline. Without these two flags the table cannot be added to amobileofflineprofile, and/setup-offline-profilewill have to fix them via a separate metadata PUT (the/enable-tables-offlineskill handles that, but it doubles the metadata-lock-serialized round trips). Empirically verified 2026-05-18 in the chanel-rm demo: 7 custom tables created without these flags caused 7 prereq-revert drift entries; fixed by post-hoc enablement. Default these totruefor all UserOwned tables created by/add-dataverseunless the user has explicitly opted out of offline support. The flags are no-ops at runtime for apps that don't use offline profiles.
{
"@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata",
"SchemaName": "cr123_jobsite",
"DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Job Site", "LanguageCode": 1033 }] },
"DisplayCollectionName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Job Sites", "LanguageCode": 1033 }] },
"OwnershipType": "UserOwned",
"HasActivities": false,
"HasNotes": false,
"IsAvailableOffline": true,
"ChangeTrackingEnabled": true,
"PrimaryNameAttribute": "cr123_sitename",
"Attributes": [
{
"@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata",
"SchemaName": "cr123_sitename",
"DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Site Name", "LanguageCode": 1033 }] },
"MaxLength": 200,
"FormatName": { "Value": "Text" },
"RequiredLevel": { "Value": "ApplicationRequired" },
"IsPrimaryName": true
},
{
"@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata",
"SchemaName": "cr123_address",
"DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Address", "LanguageCode": 1033 }] },
"MaxLength": 500,
"FormatName": { "Value": "Text" },
"RequiredLevel": { "Value": "None" }
},
{
"@odata.type": "Microsoft.Dynamics.CRM.IntegerAttributeMetadata",
"SchemaName": "cr123_squarefeet",
"DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Square Feet", "LanguageCode": 1033 }] },
"RequiredLevel": { "Value": "None" },
"MinValue": 0,
"MaxValue": 2147483647,
"Format": "None"
},
{
"@odata.type": "Microsoft.Dynamics.CRM.BooleanAttributeMetadata",
"SchemaName": "cr123_active",
"DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Active", "LanguageCode": 1033 }] },
"RequiredLevel": { "Value": "None" },
"DefaultValue": true,
"OptionSet": {
"@odata.type": "Microsoft.Dynamics.CRM.BooleanOptionSetMetadata",
"TrueOption": { "Value": 1, "Label": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "Yes", "LanguageCode": 1033 }] } },
"FalseOption": { "Value": 0, "Label": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "No", "LanguageCode": 1033 }] } }
}
}
]
}
For each Extend decision, POST a new column to the existing table.
β οΈ Per-column pre-flight (HARD β required for idempotent re-runs). Before each column POST, probe whether the column already exists. This catches:
- Partial failures from a prior
EntityDefinitionsPOST that created the table + some columns but not all (the body is non-atomic β server commits each Attribute one at a time).- User re-runs after fixing a typo in one column's metadata.
- Re-applying a plan after a network drop mid-Step-5b.
Without this check, the second POST returns
400: attribute already exists(0x80044153) and the run aborts mid-tier.node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET \ "EntityDefinitions(LogicalName='<table>')/Attributes(LogicalName='<column>')?\$select=LogicalName,AttributeType"
Status Meaning Action 404 Column doesn't exist Proceed with POST. 200 + AttributeTypematches the specAlready created (idempotent re-run) Skip the POST, log β» <column> (already exists, skipped), continue.200 + AttributeTypediffers from the specSchema drift β column type was changed manually OR plan changed since last run STOP and surface to user: "Column <column>exists but is<existingType>, plan expected<plannedType>. Dataverse does NOT allow column-type changes via API β you must delete the column manually and re-run." Do NOT silently overwrite.
After pre-flight returns 404, POST the column (always pass --solution):
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> POST \
"EntityDefinitions(LogicalName='<table>')/Attributes" \
--body '<column-json>' \
--solution '<solution-uniquename-from-memory-bank>'
The same pre-flight applies inside Create POSTs that include initial Attributes. If a Create POST partially failed earlier (table + some columns committed), the retry path is to do per-column pre-flight + POST instead of re-POSTing the whole EntityDefinitions body β re-POSTing returns 0x80060888 entity already exists. After Step 5a says "table exists with our MetadataId" (idempotent re-run match), iterate the planned Attributes and pre-flight each one against /Attributes(LogicalName='<column>'), then POST only the missing ones.
Column shapes that have non-obvious gotchas (handle carefully):
-
Lookup β POST to
/RelationshipDefinitions, not/Attributes.β οΈ Do NOT improvise the body. Copy the skeleton below verbatim and replace only the placeholders in
<>brackets.Fields that cause silent failure if added:
- Do NOT include
ReferencingAttribute. Dataverse auto-creates the foreign-key column fromLookup.SchemaName. Including it causes404: Could not find an attribute with specified namebecause the column doesn't exist yet at POST time. - Do NOT include
Lookup.LogicalName. It's read-only metadata; including it returns400 Bad Request. - Do NOT include
ReferencedAttribute. Dataverse resolves the primary key of the referenced entity automatically. The reference is optional and omitting it is the correct default.
Required fields:
SchemaName,ReferencedEntity,ReferencingEntity,Lookup.{@odata.type, SchemaName, DisplayName, RequiredLevel},AssociatedMenuConfiguration,CascadeConfiguration(includingRollupView). Anything else is invented β drop it.{ "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", "SchemaName": "<prefix>_<Parent>_<Child>", "ReferencedEntity": "<parent_table_logical_name>", "ReferencingEntity": "<child_table_logical_name>", "Lookup": { "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", "SchemaName": "<Prefix>_<Parent>Id", "DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", "Label": "<Parent Display Name>", "LanguageCode": 1033 }] }, "RequiredLevel": { "Value": "None" } }, "AssociatedMenuConfiguration": { "Behavior": "UseCollectionName", "Group": "Details", "Order": 10000 }, "CascadeConfiguration": { "Assign": "NoCascade", "Delete": "RemoveLink", "Merge": "NoCascade", "Reparent": "NoCascade", "Share": "NoCascade", "Unshare": "NoCascade", "RollupView": "NoCascade" } }Invocation (apiPath is
RelationshipDefinitions, body via--body, always pass--solution):node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> POST \ RelationshipDefinitions \ --body '<json-body-from-skeleton-above>' \ --solution '<solution-uniquename-from-memory-bank>' - Do NOT include
-
Many-to-Many (M:N) β also POST to
/RelationshipDefinitions, but withManyToManyRelationshipMetadata. Dataverse creates an auto-named intersect table.β οΈ Do NOT improvise the body. Required fields:
SchemaName,Entity1LogicalName,Entity2LogicalName,IntersectEntityName, and the twoAssociatedMenuConfigurationblocks. Do not include lookup or cascade fields β those are 1:N concepts.{ "@odata.type": "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata", "SchemaName": "<prefix>_<table1>_<table2>", "Entity1LogicalName": "<table1_logical_name>", "Entity2LogicalName": "<table2_logical_name>", "IntersectEntityName": "<prefix>_<table1>_<table2>", "Entity1AssociatedMenuConfiguration": { "Behavior": "UseLabel", "Group": "Details", "Label": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", "Label": "<Table2 Plural>", "LanguageCode": 1033 }] }, "Order": 10000 }, "Entity2AssociatedMenuConfiguration": { "Behavior": "UseLabel", "Group": "Details", "Label": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", "Label": "<Table1 Plural>", "LanguageCode": 1033 }] }, "Order": 10000 } }Pre-flight M:N: GET
RelationshipDefinitions(SchemaName='<prefix>_<table1>_<table2>')?$select=SchemaNameβ 404 β proceed; 200 β skip (already exists).In the generated service: M:N relationships are queried via the intersect entity name (e.g.,
cr123_tag_inspection) β the SDK does not expose a direct M:N navigation helper; the screen-builder must query the intersect table directly or via a calculated column approach. Flag this in the Step 7 summary if any M:N relationships are created. -
Column
@odata.typeand required fields β reference table (verified against Dataverse OData API):Dataverse type @odata.typeRequired extra fields Single-line text Microsoft.Dynamics.CRM.StringAttributeMetadataMaxLength(200),FormatName: { "Value": "Text" }β values:Text,Email,Url,Phone,TextAreaMulti-line text Microsoft.Dynamics.CRM.MemoAttributeMetadataMaxLength(10000),Format: "TextArea"Whole number Microsoft.Dynamics.CRM.IntegerAttributeMetadataMinValue,MaxValue,Format: "None"Decimal Microsoft.Dynamics.CRM.DecimalAttributeMetadataMinValue,MaxValue,Precision(2)Currency (Money) Microsoft.Dynamics.CRM.MoneyAttributeMetadataMinValue,MaxValue,Precision(2),PrecisionSource(2)Date/Time Microsoft.Dynamics.CRM.DateTimeAttributeMetadataFormat: "DateAndTime"or"DateOnly",DateTimeBehavior: { "Value": "UserLocal" }Boolean Microsoft.Dynamics.CRM.BooleanAttributeMetadataDefaultValue,OptionSetwithTrueOption/FalseOptionChoice (picklist) Microsoft.Dynamics.CRM.PicklistAttributeMetadataOptionSetwithIsGlobal: false,OptionSetType: "Picklist",Options[]β option integer values start at100000000and increment by 1Lookup via RelationshipDefinitionsβ see 1:N skeleton aboveβ Image Microsoft.Dynamics.CRM.ImageAttributeMetadataMaxHeight,MaxWidthFile Microsoft.Dynamics.CRM.FileAttributeMetadataMaxSizeInKBCommon mistake: omitting
FormatNameon String columns andDateTimeBehavioron DateTime columns. Both are required β Dataverse rejects the POST without them. -
Choice (option set) β set
OptionSet.IsGlobal: falsefor local picklists. Full body (option values start at100000000and increment by 1):{ "@odata.type": "Microsoft.Dynamics.CRM.PicklistAttributeMetadata", "SchemaName": "<Prefix>_<ColumnName>", "DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", "Label": "<Display Name>", "LanguageCode": 1033 }] }, "RequiredLevel": { "Value": "None" }, "OptionSet": { "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata", "IsGlobal": false, "OptionSetType": "Picklist", "Options": [ { "Value": 100000000, "Label": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", "Label": "Option 1", "LanguageCode": 1033 }] } }, { "Value": 100000001, "Label": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", "Label": "Option 2", "LanguageCode": 1033 }] } } ] } } -
Image β
@odata.type: Microsoft.Dynamics.CRM.ImageAttributeMetadata,MaxHeight/MaxWidthrequired -
File β
@odata.type: Microsoft.Dynamics.CRM.FileAttributeMetadata,MaxSizeInKBrequired
If the column type is not a simple string/int/boolean, surface a one-line confirmation to the user before posting.
After all mutations, re-run the existing-tables query (Step 4) to confirm everything landed.
Step 5c β Create calculated columns from ### Cross-entity Reads
Print before starting:
"β Creating calculated columns from the plan's ### Cross-entity Reads subsection (one HTTP call per row). Skip if the subsection is absent."
Run condition: the planner / data-model-architect emits a ### Cross-entity Reads (auto-derived from screen plan) subsection inside ## Data Model of native-app-plan.md when the screen plan reads any field from a related entity. Parse that subsection. If absent or empty, skip Step 5c entirely β proceed to Step 6.
This step exists because of the runtime constraint documented at shared/references/data-performance.md Β§ Cross-entity Reads: the SDK has no $expand, so cross-entity fields on hot paths (lists, dashboards, tab roots) MUST be denormalized via calculated columns at the data-model layer. The ### Chained-fetch fields (informational) subsection (if present) is documentation only β the screen-builder handles those at scaffold time, no schema change.
Algorithm:
-
Parse the
### Cross-entity Readstable. Each row has columns:Calc column | On table | Type | Resolves | Driven by. -
Run AFTER all regular columns + relationships from Step 5b have been created (the formula chain references real columns + lookups; creating the calc column before its dependencies returns HTTP 400 from Dataverse).
-
Per row, invoke the helper:
node "${PLUGIN_ROOT}/scripts/create-calculated-column.js" <envUrl> \ --table <on-table> \ --column <calc-column-logical-name> \ --type <Type column verbatim: string|datetime|decimal|integer|boolean> \ --formula "<dotted path from Resolves column, e.g. cr3e9_flightid.cr3e9_gateid.cr3e9_gatename>" \ --display "<human-friendly label inferred from the column name minus _calc suffix>" \ --solution '<solution-uniquename-from-memory-bank>' -
One at a time, sequentially. Calc-column creation is metadata mutation β same concurrency rule as table creation. Print
β <calc-column>after each success. -
On failure β the helper script prints the OData error inline. Common cases:
400 β formula references unknown attributeβ the relationship or column the formula needs has not been created yet. Verify Step 5b finished cleanly before retrying.400 β calculated formula not allowed on this navigationβ the dotted path tries to traverse 1:many or M:N. The architect should have caught this at Step 6a; flag in summary, skip the row, continue.- Surface non-recoverable errors to the user with the offending row, then proceed to the next row. Do NOT abort the whole step on one bad row.
-
After all rows are processed, the publish step (Step 6b) below picks up calc columns automatically β no extra publish call needed.
Step 5d β Create alternate keys for unique business identifiers
Print before starting:
"β Creating alternate keys for columns marked unique in the data model (one HTTP call per key). Skip if no unique columns are planned."
Run condition: the ## Data Model section marks a non-primary column as unique / alternate key / natural key. Common examples: QR Code Value, SKU, external ID, employee number, asset tag. Skip primary IDs and skip columns whose type Dataverse cannot index as an alternate key (file/image, memo/long text, multi-select choice, calculated/rollup, customer/owner lookups).
Ordering: run after the target table and target columns exist, and before Step 6b publish. Alternate-key index activation is asynchronous; creation may return success while the key status is Pending.
Do NOT use the CreateEntityKey action route. In practice it can return 404 depending on route shape / environment. The reliable metadata route is POSTing to the table's Keys navigation collection:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> POST \
"EntityDefinitions(LogicalName='<table>')/Keys" \
--body '<entity-key-json>' \
--solution '<solution-uniquename-from-memory-bank>'
Body skeleton:
{
"@odata.type": "Microsoft.Dynamics.CRM.EntityKeyMetadata",
"SchemaName": "<prefix>_<table>_<column>_key",
"DisplayName": { "@odata.type": "Microsoft.Dynamics.CRM.Label", "LocalizedLabels": [{ "Label": "<Column display> Key", "LanguageCode": 1033 }] },
"KeyAttributes": ["<column_logical_name>"]
}
Pre-flight each key before POST so re-runs are idempotent:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions(LogicalName='<table>')?\$select=LogicalName&\$expand=Keys(\$select=SchemaName,KeyAttributes,EntityKeyIndexStatus)"
| Existing key state | Action |
|---|---|
Same SchemaName or same KeyAttributes exists with Active / Pending | Skip POST; record the key in .datamodel-manifest.json. |
Same SchemaName exists with Failed | Surface the failure and stop; Dataverse requires deleting/recreating the key manually or changing the planned key name. |
| No matching key | POST to EntityDefinitions(LogicalName='<table>')/Keys. |
After POST: a 204 response is success. Re-query the Keys expand above and capture EntityKeyIndexStatus. If it is Pending, continue the scaffold but add a memory-bank follow-up: alternate key <schema> pending index activation. Do not rely on duplicate enforcement in manual tests until the status reaches Active.
Add alternate keys to .datamodel-manifest.json for the table:
"alternateKeys": [
{ "schemaName": "cr123_item_code_key", "keyAttributes": ["cr123_code"], "indexStatus": "Pending" }
]
Step 6 β Add data sources
Print before starting:
"β Generating TypeScript services for <N> tables via
npx power-apps add-data-source(sequential). Print 'β <table>Service.ts' after each."
For each table the app will use (regardless of reuse/extend/create), generate the TS layer from the app root. The CLI reads the environment ID from power.config.json; pass the environment URL resolved earlier in the skill:
npx power-apps add-data-source --api-id dataverse --org-url <envUrl> --resource-name <table-logical-name>
Run one at a time β sequentially, not in parallel. The Power Apps CLI writes src/generated/connectorSchemas.ts and other generated files non-atomically; concurrent invocations corrupt them.
Step 6b β Publish customizations
Print before starting:
"β Publishing customizations (PublishXml) so new tables/columns become queryable. ~5β20 seconds."
Only after every Step 5 metadata POST and every Step 6 npx power-apps add-data-source has returned successfully, publish so the new tables and columns are available to the runtime. PublishXml takes the same exclusive metadata lock as the create/extend calls β do not run it concurrently with anything from Steps 5 or 6.
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> POST \
"PublishXml" \
--body "{\"ParameterXml\":\"<importexportxml><entities><entity>cr123_table1</entity><entity>cr123_table2</entity></entities></importexportxml>\"}"
Build the entity list from all tables that were created or extended in Steps 4β5. Skip reused-as-is tables β they don't need republishing.
If the publish call returns a non-2xx status, report the error and stop β do not proceed. The user must resolve before the tables are usable.
Step 6c β Verify tables exist
For each created or extended table, confirm it is queryable after publish:
node "${CLAUDE_SKILL_DIR}/../../scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions(LogicalName='<table>')?\\$select=LogicalName,DisplayName"
- 200 β confirmed.
- 404 β table missing after publish β report and stop.
Step 6d β Write .datamodel-manifest.json
After all tables are verified, write the manifest to the project root using the Write tool:
{
"environmentUrl": "<envUrl>",
"generatedAt": "<ISO timestamp>",
"tables": [
{
"logicalName": "cr123_jobsite",
"displayName": "Job Site",
"status": "new",
"metadataId": "<server-assigned GUID from Step 5a re-GET>",
"solution": "<solution unique name, e.g. PowerAppsDefault>",
"columns": [
{ "logicalName": "cr123_sitename", "type": "String" },
{ "logicalName": "cr123_address", "type": "String" }
]
}
]
}
metadataId and solution are required for status: "new" or "extended" entries β they're how Step 5a distinguishes "we own this on a re-run" from "name collision." Reused tables can omit both.
Include only tables confirmed in Step 6c. Do NOT include tables reused with no schema changes.
Step 7 β Inspect generated files
Glob: src/generated/services/*Service.ts
Glob: src/generated/models/*Model.ts
For each table, check the generated service exposes the expected methods:
Grep pattern="async (create|getAll|getById|update|delete|upload|downloadFile|downloadImage)" path="src/generated/services/<Table>Service.ts"
If the table has file or image columns, confirm the service includes upload, downloadFile, downloadImage, deleteFileOrImage β and the model exposes <Table>FileColumnName / <Table>ImageColumnName union types.
File/image column UI controls: When a generated table has File or Image columns, note this in the summary so screen-builders apply the host controls from @microsoft/power-apps-native-host:
- File columns β
<FilePicker>; upload bytes separately via the generated service's upload method after the main create/update. - Image columns β
<ImagePicker>; capturePickedImageInfoviaonImageChangeand persist through generatedupload(...)after the main create/update. - Read/view flows β use generated
downloadFile(...)/downloadImage(...)helpers for existing attachments/previews.
Full usage pattern and the native-wrapper boundary live in /add-native; screen-builder keeps only the concise JSX enforcement rule.
PDF/signature artifact schema guidance: If the approved plan mentions generated PDFs, PDF evidence packets, approvals, signatures, sign-off, ink, or drawings, preserve the storage decision in the Dataverse model instead of defaulting to text fields.
| User need | Dataverse shape | Write pattern |
|---|---|---|
| Generated PDF report that must be retained | File column on the parent record, or child Evidence/Attachment table with a File column | Create/update parent row first, then call generated Service.upload(parentId, '<fileColumn>', file) |
| Generated PDF report that is only transient | No Dataverse column required | Generate locally with expo-print only when present; share with expo-sharing only when present; do not route local URI to native PDF viewer |
| Captured signature/sign-off image | Image column when the latest signature belongs on the parent row | Strip data:image/png;base64, if the generated service expects raw base64, then include image payload in the update body |
| Multiple signatures, sketches, evidence images, or audit attachments | Child Evidence/Attachment table with Image/File columns and lookup to parent | Create child row first, then include Image payload or upload File bytes through generated service helpers |
Signature image normalization example:
const signatureBase64 = signatureDataUri.replace(/^data:image\/png;base64,/, '');
const result = await Cr123_approvalService.update(approvalId, {
cr123_signatureimage: signatureBase64,
cr123_signedat: new Date().toISOString(),
});
if (!result.success) {
throw new Error(result.error?.message ?? 'Signature image was not saved.');
}
File upload after parent row exists example:
const save = await Cr123_inspectionService.update(inspectionId, {
cr123_reportgeneratedat: new Date().toISOString(),
});
if (!save.success) {
throw new Error(save.error?.message ?? 'Inspection was not saved.');
}
const upload = await Cr123_inspectionService.upload(inspectionId, 'cr123_reportfile', reportFile);
if (!upload.success) {
throw new Error(upload.error?.message ?? 'Inspection report was not uploaded.');
}
Step 8 β Type-check
Print before starting:
"β Regenerating connector schemas + running tsc to verify generated services compile (~15β30 seconds)."
npx power-apps add-data-source (Step 5) wrote new files into .power/schemas/<connector>/. The connectorSchemas.ts consumed by app/_layout.tsx is now stale β regenerate it before type-checking, otherwise the new tables won't be wired into the runtime schema map and tsc will pass against an out-of-date snapshot:
npm run generate-schemas
npx tsc --noEmit
Fix any errors. Common: missing peer dependencies β npx expo install <package>.
Step 8.5 β Offline profile reconciliation
A schema change here (new table or new column) can leave an existing Mobile Offline Profile behind β new tables never sync to devices and new columns come down blank. Reconcile the profile with what you just created.
Skip this step entirely when $ARGUMENTS contains --skip-planning (the orchestrator-invoked path). /create-mobile-app, /setup-datamodel, and /edit-app own offline reconciliation in their own flow, so running it here too would double-prompt.
Otherwise (manual /add-dataverse), run the local, no-network delta check:
node "${CLAUDE_SKILL_DIR}/../../scripts/offline-profile-delta.js"
Branch on the JSON status per offline-profile-reconciliation.md:
status | Action |
|---|---|
no-manifest / no-profile / in-sync | Continue to Step 9 silently. For no-profile (no offline profile exists) do not nag β the app may not use offline. |
error | offline-profile.json is unreadable β the script prints status: error and exits non-zero. Do NOT treat this as an /add-dataverse failure (the tables are already created): surface the error string, skip reconciliation (never drive the update skills against a corrupt file), and finish with DONE_WITH_CONCERNS telling the user to fix offline-profile.json, then run /add-table-to-offline-profile manually. |
delta | Prompt the user (one AskUserQuestion, default = update now) to add the missing tables / new columns to the offline profile, then invoke /add-table-to-offline-profile (for missingTables[]) and /edit-offline-profile --table <t> --columns add:<newColumns> (for tablesWithNewColumns[]). Re-run the delta check; it should read in-sync. Follow the exact prompt + ordering in the reconciliation reference. |
Step 9 β Summary
β
Dataverse added
βββββββββββββββββββββββββββββββββββββββββββββ
Environment : <envUrl>
Tables reused : <list>
Tables extended: <list (columns added)>
Tables created : <list (in tier order)>
Generated services:
src/generated/services/<Table>Service.ts Γ N
Generated models:
src/generated/models/<Table>Model.ts Γ N
Type-check: PASS
Sample usage:
import { Cr123_jobsiteService } from '../../src/generated/services/Cr123_jobsiteService';
const result = await Cr123_jobsiteService.getAll({
select: ['cr123_sitename', 'cr123_address'],
filter: 'statecode eq 0',
orderBy: ['cr123_sitename asc'],
top: 50,
});
const sites = result.data ?? [];
β οΈ First call triggers Dataverse OAuth consent via the native player's
`<scheme>://oauth-callback` deep link.
Next:
/add-sample-data # Seed each new table with 5-10 realistic rows so the
# app's home screen shows real-looking data on first launch.
βββββββββββββββββββββββββββββββββββββββββββββ
After printing the summary, offer one-click sample-data seeding β but only when invoked manually (not from /create-mobile-app, which handles this in its own Step 8.5).
-
If
$ARGUMENTScontains--skip-planning(the orchestrator-invoked path): skip the prompt. The orchestrator invokes/add-sample-dataseparately. -
Otherwise (manual invocation), if the manifest contains any tables, ask:
"Seed <N> tables with sample records so the app shows real-looking data on first launch? (yes / no β default: yes)"
Default to "yes" so empty input auto-proceeds. On "yes", invoke
/add-sample-data. On "no", print "β Skipped sample data. Run/add-sample-datalater to populate." and stop.
Key Rules
- Always use generated services (e.g.,
Cr123_jobsiteService.getAll()) β neverfetch/axiosdirectly. - Result data lives at
result.data, notresultitself. - Don't edit files under
src/generated/β they are regenerated on everynpx power-apps add-data-source. - Picklist (Choice) fields, virtual fields, lookups, and file/image columns each have non-obvious gotchas. Keep
references/dataverse-reference.mdaligned with this skill. - When a Dataverse Web API behavior is uncertain (lookup write syntax,
$expandnav property names, choice column shape, batch semantics, error format), query themicrosoft-learnMCP server before guessing. See shared/shared-instructions.md β Microsoft Learn MCP. Guessed Dataverse syntax silently 400s.
Reference
scripts/dataverse-request.jsβ bundled in this plugin- shared/references/offline-profile-reconciliation.md β Step 8.5 offline delta check + reconciliation flow
What ships with it: 1 file
23.0 KB alongside SKILL.md
references/
- dataverse-reference.md23.0 KB