AGENTS.md@plugins/model-apps · diff
git:20260818.8251d2c to git:20260821.87b905f
23 added, 2 removed. Audit A to A.
# AGENTS.md — Model Apps Plugin
This file provides guidance to AI Agents when working with the **model-apps** plugin.
## What This Plugin Is
A plugin for building Power Apps for **model-driven apps**. Two **authoring** skills do the work
(plus `/report-issue` and `/telemetry` — four user-invocable skills in total):
- **`/genpage`** — build and deploy standalone **generative pages** (genux): React 17 + TypeScript +
Fluent UI V9 single-file components, deployed via PAC CLI. Orchestrates specialist agents (planner,
optional entity builder, parallel page builders).
- **`/app-builder`** *(Preview)* — build and edit a **whole model-driven app** (tables, columns,
relationships, adaptive forms, views, charts, generative pages, app + sitemap, sample data, and
admin-gated AI features) from a natural-language intent, via the vendored headless `cds-maker-sdk`.
**The two authoring skills are independent entry points — neither requires the other.** Use `/genpage`
to add pages to an app that already exists; use `/app-builder` to build or edit a whole app.
`/app-builder` does *reuse* `genpage-page-builder` to generate its page `.tsx` (Phase 1.5), but that
is an implementation detail of code generation, not a dependency: `/genpage` never invokes
`/app-builder`, and `/app-builder` never invokes the `/genpage` skill. They install together (the
marketplace copies the whole plugin directory), but **either can be invoked without the other, and
neither leaves state the other depends on**. Keep it that way — shared **agents and libraries** are
fine, a skill-to-skill call is not, and neither may write a file the other treats as authoritative
(this is why `write-page-plan.js` emits `app-builder-page-plan.md`, not `genpage-plan.md`).
Plus **`/report-issue`** to file bugs against this repo. All Dataverse mutation flows through the
shared, vendored SDK (`scripts/vendor/cds-maker-sdk.cjs`) — see `## Building & Testing`.
**Requirements:**
**Requirements:**
- **PAC CLI > 2.10.0** — for app and generative-page deploy operations (incl. the genpage `upload` connector/Custom API flags)
- **Azure CLI (`az`)** — Dataverse Web API auth (SDK + entity builder); must be logged in with the
same identity as the active `pac` profile
No Dataverse Skills plugin or Python dependency.
## Documentation Map
Keep these in sync — **update the relevant doc(s) in the same PR as the change** (a reviewer should be
able to tell what moved from the docs alone):
| Doc | What it holds | Update when… |
|-----|---------------|--------------|
| `AGENTS.md` (this file — `CLAUDE.md` symlinks to it) | Per-component behavioral specs, the canonical file tree, conventions, build/test | You add/rename a script, change a component's behavior, or change how to build/test |
| [`docs/architecture.md`](docs/architecture.md) | Wiring / flow **diagrams** for both skills (`/genpage` + `/app-builder`) | You change the orchestration, phase pipeline, or how the pieces connect |
| [`docs/app-builder-roadmap.md`](docs/app-builder-roadmap.md) | `/app-builder` **roadmap / TODO** (Complete + Pending by phase) | You ship or reprioritize an app-builder capability |
| [`docs/app-builder-design.md`](docs/app-builder-design.md) | `/app-builder` **design record** — Part I staged-flow architecture (**cited from code by section number — never renumber**), Part II the `--changed-only` contract | You change the staged flow or the partial-apply contract |
| [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog — concise bullets (detail lives in PRs/docs) | Any user-visible change |
| [`references/app-spec-schema.md`](references/app-spec-schema.md) | The App Spec contract | You change the App Spec shape or validation |
Don't duplicate content across these — **cross-link instead** (a second copy only drifts, as the file
tree and teardown order both did before).
**This repo is public.** Before adding to any of these docs, re-read the repo-root `AGENTS.md` →
*"This Repo Is PUBLIC"*. The docs here have already had to be scrubbed once for internal repo paths,
a real Dataverse environment name, review provenance, and indexes into documents an outside reader
cannot open. Record that kind of context in the PR conversation instead.
## app-builder — intent → model-driven app
A second skill (`/app-builder`) builds a whole **model-driven app** (tables, columns,
relationships, adaptive forms with sub-grids, views, Choice-column charts, app module +
sitemap) from a natural-language intent — distinct from `/genpage`, which builds generative
*pages*. The **whole flow runs in the main conversation loop, never a `Task` subagent** — subagents
are headless, so `AskUserQuestion` and plan mode cannot reach the user. For the end-to-end flow,
stage→phase mapping and page-identity model, see
[`docs/architecture.md`](docs/architecture.md) → `## /app-builder — build pipeline`; that doc owns
the pipeline and delegates each script's **behavioral spec** to the entries below.
- **`references/authoring-flow.md`** — the Phase-1 authoring playbook the skill executes itself:
validate prereqs, select the env via PAC (`pac auth list` / `pac org who`), detect existing
tables/apps, author the **App Spec** in confirmed levels (**(a0) personas & jobs-to-be-done
first** — they drive which tables and surfaces exist — then data model, then forms/views/charts +
page-intents + sample data, then access), run the `spec-lint.js` guardrail, get plan-mode approval.
Writes `app-spec.json` (the machine contract) + `model-app-plan.md` (rendered by
`scripts/write-app-spec-doc.js`, never hand-written).
**Artifact naming:** both skills derive their working directory from a slug off the user's
request, so they can land on the SAME folder and file names are a namespace. A file written by
BOTH is unprefixed (`workflow-log.md`); a file owned by ONE is prefixed with it
(`genpage-plan.md`, `genpage-entity-creation-log.md`, `app-builder-page-plan.md`). A name chosen
to prevent confusion must not *contain* the name it guards against — see
`scripts/write-page-plan.js` for why the app-builder page plan is not `genpage-*`.
- **`scripts/lib/spec-lint.js`** — pure App Spec guardrail (`lintAppSpec → { ok, errors,
warnings }`): errors block the plan gate (e.g. the relationship-name-vs-lookup-name
collision Dataverse rejects), warnings teach.
- **`scripts/build-model-app.js` → `scripts/lib/sdk-build.js`** — the deterministic, **idempotent**
build engine, run after approval. Runs in two engine invocations (staged flow): (1) `--stage data
--apply` materializes tables + columns + relationships (solution·data-model only; sample-data
deferred) so `generate-types` can emit `RuntimeTypes.ts`; (2) a full `--apply` build (re-discovers
the data model, then ui · app · publish) finalizes everything idempotently. `--stage
<data|ui|app|publish>` selects phases by stage name; **apply-safe only for `data`** — the full
build (run 2) is always a complete idempotent run. Discovers existing tables/columns/relationships via the SDK
(`findTables`/`findColumns`/`fetchEntityMetadata`) and creates only what's missing
(`createSolution`/`createTable`/`createColumn`/`createRelationship`), seeds sample data via
`seedRecordGraph` (SDK-owned parent-bind + resolve-by-name idempotency), enriches default
Active/Inactive views via `enrichDefaultViews`, then
`createWebResource` for form JS, then builds each artifact through the SDK's **generic mutation
surface** (`createArtifact`+`addElement`/`updateElement`/`removeElement`+`pushArtifact`) driven by the
pure **`scripts/lib/artifact-intent.js`** compiler: a form is a minimal `createArtifact` plus a coarse
`addElement` of each authored tab (sub-grids/quick-views are canonical control cells; form JS is the
root-bag `/bag/c` `<events>` region), a view is `updateElement('/columns')`, an app is
`updateElement('/siteMap')`, a dashboard tile is `addElement('/components')`. Form reconcile adds the
spec's fields and — for an author-controlled **explicit** layout — prunes fields it dropped (never the
primary) via `findFieldCellPointer`+`removeElement`, keyed by a declared semantic identity so a rebuild
never duplicates a control. Every push routes through `requireSuccessfulPush` (a 412 version conflict
halts the build for a fresh download instead of silently dropping the edit) — so new, existing, and mixed
envs all work. The data model is **complete** (all column types, global choices, status reasons,
alternate keys, N:N). It also builds **quick-create/quick-view forms** (`formType`) with **quick-view
placement** (`forms[].quickViews[]` — embed a QuickView form via a lookup), **modern command-bar
buttons** (`commands[]` — functional JS on-click + static hidden/disabled, incl. **flyout/split-button
menus** via `type`+`children[]`), and **dashboards** (`dashboards[]` — chart/list/iframe/webresource
tiles) with **sitemap placement** (a `dashboard` subarea auto-pins the dashboard as an app component).
Following a **genpage-first policy**, overview/dashboard/analytics surfaces are authored as **generative
pages** (`pages[]`) rather than classic dashboards — the build's `pages` phase uses a **three-authority
model**: IDENTITY (durable `<app>_pagemanifest`, outranked by a downloaded spec's `pages[].pageId`),
EXISTENCE (env-wide `pac model genpage list` — crash-safe create-vs-reuse via `enumerateEnv`), and
MEMBERSHIP (the app's sitemap `GenPageId` set, read fail-closed via `fetchSitemap` in
`scripts/lib/sitemap-pages.js` — drives placement, download enumeration, and verify; a read failure
HALTs). All page matching is by id. Every `pages[]` entry must be sitemap-placed (validation rejects
headless pages). The build halts on safety violations (`pages-removed`, `pages-shared-across-apps`,
identity conflicts, read failures). The cross-app shared-page scan (`fetchAppsForPages`) enumerates every app via the vendored SDK's
`@odata.nextLink` pagination (`queryRecords({ paginate:true })`), so it verifies EVERY app in the
environment rather than one 5000-row page; a pagination fault (the SDK's repeated-nextLink guard) still
fails **closed** rather than scanning a partial list. Classic `dashboards[]` are opt-in.
**All of the build's Dataverse access is via the SDK** (see "Dataverse Access From Scripts" for the
sanctioned exceptions elsewhere), so metadata is persisted under
`<app-folder>/.maker-workspace/` for reuse/edits. The 14 phases
(`solution·data-model·sample-data·web-resources·views·charts·forms·commands·dashboards·app-shell·pages·ai-features·security·publish`)
are unchanged; independent ops run with bounded parallelism.
Emits `[n/total]` events the orchestrator narrates + a `BuildHalt` it gates on. Dry-run by
default; `--apply` writes, `--sample-data` / `--publish` opt-in (`--publish` gates the final *bulk*
publish; edit/finalize paths — reconciling an existing form/view, form events, quick-views,
existing-app sitemap, page finalize — still publish their one artifact so the change takes effect).
+ **DATA-MODEL Dataverse labels are stamped with the ORGANIZATION's base language, not a hardcoded
+ 1033.** `resolveLanguageCode` (`scripts/lib/entity-provision.js`) reads `organization.languagecode`
+ once per build and threads it into every label-emitting SDK call in that phase (tables, columns,
+ customer columns, global choices, status reasons, alternate keys, relationships); precedence is
+ `--language-code` → App Spec `languageCode` → the org's base language → 1033. Without this, an org
+ that has not provisioned 1033 fails the data-model phase with `The language code 1033 is not a valid
+ language for this organization`
+ ([#447](https://github.com/microsoft/power-platform-skills/issues/447)) — and confusingly only on
+ *some* column types, because (observed 2026-08) Dataverse tolerates an unprovisioned LCID on
+ `EntityMetadata` and `PicklistAttributeMetadata` but rejects it on `DateTime`/`Memo`. Every fallback
+ to 1033 warns, as does an explicitly supplied LCID that had to be discarded. Note
+ `updateTable(logical, { quickCreateEnabled })` deliberately passes no language: it only builds a
+ Label when a `displayName`/`pluralName`/`description` is supplied, and otherwise round-trips
+ Dataverse's own labels under `MSCRM.MergeLabels`.
+ **Scope — this does NOT extend to the `forms`, `dashboards` or `app-shell` phases.** Those go
+ through the vendored SDK's artifact serializers, which hardcode `1033` into FormXML
+ (`<label languagecode="1033">`), SiteMap XML (`<Title LCID="1033">`) and dashboard XML with **no
+ caller override**, so the plugin cannot pass a language even though it has one. That is an SDK-side
+ gap tracked in [#455](https://github.com/microsoft/power-platform-skills/issues/455). It is not
+ known to fail a build — the #447 reporter's full build completed once the data-model phase was
+ fixed — but in a non-1033 org those labels are stored tagged with a language the org lacks.
`--verify` (opt-in) auto-runs the read-only reconcile after a successful apply and exits non-zero on a silent partial build (the same
check `verify-model-app.js` runs standalone). Recovery from a halted build is a full rerun (idempotent).
**`--changed-only`** (Preview, off by default) is a fail-closed SAFE partial apply: after a FRESH
`--apply --changed-only` baseline, a later `--apply --changed-only` for a page `.tsx` byte edit runs ONLY
the pages phase (uploads just the changed page, skips the full build) — gated on an identity-bound
snapshot (`.maker-workspace/apply-snapshot.json`); any non-page edit (or an edit to a pre-existing app)
falls back to a full build. Teardown tombstones+deletes the snapshot. See
[`docs/app-builder-design.md`](docs/app-builder-design.md) for the v1 scope + contract.
**App TABLE components are pinned by OData REFERENCE** (ADO 6612527). The SDK sends
`{ '@odata.id': '<EntitySetName>(<MetadataId>)' }` per sitemap table, NOT an `@odata.type` instance:
`Microsoft.Dynamics.CRM.entity` names a real Dataverse table (metadata-as-data), so the old instance
payload pinned the `entity` TABLE and every app exported as
`<AppModuleComponent type="1" schemaName="entity" />`. The **set segment** decides the resulting
`objectid`; an unknown set 400s, so a typo cannot silently pin the wrong table. The reference form is
also the ONLY one that can express an abstract EDM table (`activitypointer`, `principal`), which an
instance payload rejects outright. Three consequences the build depends on: ONE bad component fails
the WHOLE `AddAppComponents` call (zero rows), so an unresolvable table **HALTS** the build naming it
rather than shipping an app whose nav points at content it lacks; `AddAppComponents` does NOT
de-duplicate (N components → N rows); and because a 204 says only that the request was *accepted*,
the SDK **reads the components back** and asserts each declared table has a `componenttype: 1` row
carrying that table's MetadataId, failing closed on an inconclusive read. That last point is the
general rule this bug taught: **assert what you PRODUCED, not what you intended** — "some table
component exists" was true of the corrupt apps too, and `ValidateApp` reported success on them.
Pinned by `scripts/tests/app-entity-components-real-bundle.test.js`.
The same rule binds **tests and evals**, with a distinction that is easy to get backwards:
an EXPECTATION must come from the CONTRACT, independent of the code under test, while the FIXTURE
that stands in for the environment should be generated from the builder's real output so it cannot
drift from what ships. `smoke-eval.js` got both wrong at once: it asserted a `VectorIcon` the
builder deliberately drops, and its unit test hand-wrote sitemap XML containing that value — so the
offline suite stayed green while every live run failed. Deriving the expectation from `appDef`
instead is the opposite failure and is just as bad: when the builder stops emitting a value, a
derived "must be present" check silently becomes "must be absent" and still passes, leaving the
eval unable to contradict the very code it exists to check. Assertions are therefore fixed by
contract, the offline fixture is rendered from `appDef`, and `vendor-sdk-smoke.test.js` checks the
bytes the real vendored SDK serializes.
- **`scripts/teardown-model-app.js` → `scripts/lib/sdk-teardown.js`** — the first-class, **classifier-safe**
teardown (reverse of the build), for cleaning up live-verification probes or a failed build. Deletes
exactly the artifacts a given App Spec declares, in dependency-safe order (**app module → security
roles → dashboards → command bars → forms → charts → views → reset enriched default views to drop
parent lookups → relationships → AI row summaries → tables [reverse-topological, children-first] →
web resources (generated app icon + page manifest + declared) → global choices → solution**). Forms/charts/views/relationships
are deleted **explicitly before tables** (a table delete does not reliably cascade cross-references; it
does remove the table's own columns). **Web resources are deleted after tables**: a table's vector/raster
**icon** web resource is referenced by the table itself, so Dataverse refuses to delete it until the table
is gone (form JS, referenced by its already-deleted form, is safe either way). Teardown also removes the
build's **generated default app icon** (`<appUnique>_icon`, created in-solution when the spec sets no
`app.icon`) so it doesn't leak as an orphan. **An app is TWO rows** — an `appmodule` AND a `sitemaps`
row, with no lookup between them and no server-side cascade; the only link is
`sitemap.sitemapnameunique === appmodule.uniquename`. Deleting only the appmodule strands the sitemap
forever and, because `sitemapnameunique` is unique-constrained, permanently **burns that unique name**:
a later build of an app with the same name fails with *"The name <x> is already in use by an
existing site map"*, which the maker cannot act on. `deleteAppCascade` therefore resolves the sitemap
BEFORE deleting the app, deletes both rows in **one atomic OData `$batch`**, and **fails closed** — any
delete it cannot prove is rejected rather than guessed. This is why
**`scripts/lib/sdk-http-client.js` must implement `postRaw`**: the SDK will not fall back to two
sequential deletes, so a transport without it fails every teardown with `APP_DELETE_NOT_ATOMIC` (see
that file for the wire contract and why a `$batch` is never retried). Pinned by
`scripts/tests/app-delete-real-bundle.test.js` against the real bundle — every other teardown test
drives a mock and would stay green through a regression here.
The empty solution container goes last — but a **built-in
system solution** (`Active`/`Default`/`Basic`) is **skipped** (Dataverse 400s any delete of a restricted
solution), so a downloaded spec whose real solution could not be recovered (and defaulted to `Default`)
still tears down cleanly instead of erroring. Command teardown
removes the whole command bar for an entity the spec authored commands on (the SDK models a command bar
per entity, not per button). Every id is resolved from a spec-declared name/logical/uniquename via an
exact-match OData filter, so it can never wildcard-scan an org. **Dry-run by default** (`--apply`
writes); best-effort continue (a failed step is recorded, teardown proceeds). A not-found (already-gone)
error is treated as deleted, the table delete's **not-found-on-success** is tolerated (`tolerateNotFound`),
and system/managed artifacts that cannot be deleted are recorded as `skipped` rather than failing.
`--clear-workspace` prunes `.maker-workspace/` after a clean apply. `planTeardown(spec)` is pure (dry-run +
unit-test surface); reuses `appUniqueName`/`commandsByEntity`/`topoOrderEntities` from the build engine (DRY).
- **`scripts/download-model-app.js` → `scripts/lib/hydrate-spec.js`** — the **edit flow**: pulls a
*deployed* app back into an editable App Spec + page code (sitemap → `appShell` with icons, **every**
generative page via `pac model genpage download`, referenced entities/tables, icon web resources,
dashboards, solution).
**Round-trip scope (be precise — do not claim "complete"):** tables, sitemap/appShell, generative pages,
classic dashboards, icons, and solution round-trip; **forms, views, charts, and commands do NOT yet
round-trip.** (View hydration was tried and reverted — LIVE-verified that the deployed savedquery set
can't reliably tell app-builder-authored views from Dataverse's auto-generated Active/Inactive/QuickFind
system views: `isdefault` is TRUE on the authored primary view and FALSE on the system Inactive view, so
no filter isolates author views. Forms/charts/commands need structured reads the SDK doesn't expose.)
All four survive on the live app (a rebuild preserves them by discovery), but are absent from the
downloaded spec, so edit them in Maker or a fresh spec.
**Entities are the sitemap's tables UNIONED with the entities owned by the app's VIEW / CHART /
FORM components** (`appComponentEntities`) — a maker-built app can include tables reachable only via
a lookup/sub-grid/related view, with no sitemap entry of their own, and reconstructing from the
sitemap alone drops them. Note `componenttype eq 1` (Entities) rows are deliberately NOT used, but
the REASON changed: apps built before the entity-component fix carry junk rows that all share one
`objectid` (the `entity` metadata table's own id), so on those apps the row identifies the
component *kind*, not which table. Newly built apps now carry CORRECT per-table objectids (see the
build note below), so reading them is viable — it is not done yet because a legacy app's junk rows
would resolve to the table literally named `entity` and have to be filtered.
**Scope caveat:** `/app-builder` itself never creates a hidden component — a table
with no sitemap subarea does not become an app component at all (LIVE-verified: declaring `task` in
`entities[]` without nav left the app's component set unchanged), so for an app-builder-built app
the sitemap set already IS the complete set. This union therefore only adds tables for apps built
or edited in the maker. **ADO 6603388 (download) is still open**, and a live attempt to construct
the hidden component it describes did not succeed: pinning `task` via `AddAppComponents` with the
now-correct reference shape returned 204 but wrote no row, before or after publish. So the
download-side fix cannot currently be verified end to end — do not implement it speculatively.
The component read is best-effort: a failure degrades
to the sitemap-derived set rather than failing the download. Each entity's **`primaryAttribute` comes from
real Dataverse metadata** (`primaryNameAttribute`) and is **never synthesized**. The old
`<entity>_name` guess was wrong for most OOB tables (`account` → `name`,
`contact` → `fullname`) while looking plausible on custom ones, which is why it went unnoticed.
Because validation *requires* `primaryAttribute`, a table without one cannot simply be emitted: a
**sitemap** table missing it FAILS the download (actionable — the user asked for that table), while a
**component-only** table missing it is dropped with a warning (it arrived via a best-effort read and
was absent from the spec entirely before this change, so aborting over it would regress a previously
working download with no override flag).
The **solution** is recovered as the app's one *real* unmanaged solution — `recoverAppSolution` enumerates
the app's solution memberships and excludes the built-in `Active`/`Default`/`Basic` system solutions the
app is also a member of (see `scripts/lib/system-solutions.js`), so the downloaded spec can cleanly tear
down its own solution instead of targeting the restricted `Default`. Its **publisher prefix is read from
that solution's owning publisher** via the SDK's `getSolution` — NOT inferred from the app's uniquename,
which is silent-wrong whenever the app name doesn't encode the publisher (an app named
`new_customermanagement` inside publisher `contoso`, an app with no prefix, or a prefix longer than the
guess assumed all collapsed to a literal `new`). The app-derived value remains the fallback, and
`prefixResolved` is true for BOTH trusted sources — it gates the icon own-vs-foreign classification, so
a downgrade there silently stops custom nav icons from round-tripping. Recovered **tables are flagged
`existing: true`**, so a teardown of a downloaded spec never deletes a table (+ its data) this build
cannot prove it created — download can't distinguish app-created from merely-referenced tables, so it
fails safe (an orphaned table is recoverable; deleted customer data is not).
Edit the downloaded spec and re-run the build (idempotent) — create and edit share one path. Always
pull fresh at the start of an edit session (the build reads an etag; a write against an artifact
changed in Maker throws a version conflict → re-pull, never clobber). **Classic DashBoard subareas
round-trip** too — `readDashboards` reconstructs each into `dashboards[]` with **id-passthrough tiles**
(every tile carries the deployed view/chart ids), so a rebuild recreates the dashboard against the
existing views/charts without re-declaring them (genpage/entity/URL subareas round-trip losslessly). A
dashboard whose tiles cannot be reconstructed is dropped and surfaced in `droppedSubareas`.
- **`scripts/verify-model-app.js` → `scripts/lib/verify-spec.js`** — read-only reconcile of the App Spec
against what actually deployed; exits non-zero and lists anything missing, catching silent partial
builds. Checks **existence** (entities/columns/views/charts/forms + sitemap subareas + icons + pages by
id) AND, best-effort, **content** so an *unapplied edit* is caught (not just a missing artifact): a
view's **column set** (parsed from `layoutxml` — the additive `reconcileView` won't drop a removed spec
column, so this flags it), plus **relationship** and **command-bar existence** (previously unchecked).
Content checks are additive + reader-gated (they only fire when the reader supplies `layoutxml` /
`entityRelationships` / `commandBar`), so existence-only callers are unaffected. It also reconciles
**AI app features**: for every `ai.appFeatures` entry it proves an APP-SCOPE OVERRIDE ROW exists in
`appsettings` holding the requested value. Verify previously had no awareness of `spec.ai` at all, so
a run whose every AI feature was skipped (admin gate off) or silently not persisted still reported a
clean PASS. The oracle is deliberately the override row and NOT the effective value:
`RetrieveSetting(name, { appUniqueName })` **falls back to the environment value** when the app has no
override, so an effective-value compare passes whenever the environment already holds the requested
value — a false PASS for an app that was never configured, and the same oracle the SDK uses for its
`applied` bucket. It fails CLOSED when the proof cannot be read, and the check needs BOTH
`retrieveSetting` and `queryRecords` on the reader (an existence-only reader skips it rather than
degrading to the unsound compare). Note the per-app settings are DISTINCT from the org readiness gates —
`nlSearch`'s gate is the boolean `EnableNLGridSearch` but its per-app setting is the numeric
`NLGridSearchSetting`; conflating them is what let NL grid search report "applied" while writing
nothing. This is the F5 "convergence" mitigation: the build is additive (edits to existing artifacts
aren't re-applied in place — teardown + rebuild to converge), and verify makes any resulting
divergence **loud**.
- **`scripts/ai-preflight.js`** — standalone preflight report: prints each AI feature's on/off status
and the exact admin action needed (Power Platform Admin Center → Environments → Settings → Product →
Features) for anything off. Never fails. The `ai-features` build phase calls this logic internally and
uses `RetrieveSetting`/`SaveSettingValue` (SDK) for app-level feature flags and `AIModelPublish` +
`aiskillconfigs` for per-table row summaries. Feature values are `true`/`false` (the numeric settings'
1/0) or an explicit integer such as `2` ("on for everyone"), bounded to `0..1000000` — the same range
the SDK enforces, so validation rejects an out-of-range value up front instead of aborting the build
half-applied. The SDK **proves every write** against the app-scope override row, retrying with backoff
(an immediate read can still return the environment fallback, which previously produced a false
`notPersisted` on first apply). `applied` is the ONLY success bucket; a feature otherwise lands in
`skipped` (org gate off), `notPersisted` (no override observed for the whole retry budget — Dataverse
can accept an app-scope `SaveSettingValue` with HTTP 204 and store nothing), `unverified` (the write
was issued but the proof could not be read) or `failed` (the write threw; the rest of the batch still
reports). The build surfaces **every** non-success bucket as a `⊘` warning plus in the phase detail
— buckets are read off the result object, so one a future SDK adds is reported verbatim rather than
silently dropped — and `--verify` fails on any of them. **An app-scope setting WRITE is a no-op until
the app is published** (live-measured: the write reports `notPersisted` and `appsettings` holds no row
at all, while publishing and re-issuing the same call applies every feature). This is not read lag, so
re-*proving* after publish cannot fix it — the build **re-issues** the write after publish for anything
the first attempt did not apply. Verification proves the override ROW, keyed by `appmoduleid`, so the
build passes the id it already holds rather than have the SDK resolve it by name (an unpublished
appmodule is not readable). See the `ai-features` phase in `scripts/lib/sdk-build.js` for the full
sequence and its bounds.
The flag set is resolved ONCE by `scripts/lib/ai-app-settings.js` and shared by the build and the
verifier: a spec with an `ai` block but no `ai.appFeatures` still gets defaults written, so
reconciling only the DECLARED features left them applied-but-unverified.
All AI features are **admin-gated**: the skill preflights
and skips/warns; it cannot flip admin or tenant switches. `scripts/lib/ai-candidates.js` selects
good-candidate tables for auto row-summary mode; `scripts/lib/ai-prompt.js` generates tailored summary
prompts. The `ai` block in the App Spec configures the full set; see
[`references/app-spec-schema.md`](references/app-spec-schema.md) → `## ai`.
- **`scripts/preview-form.js` → `scripts/lib/form-preview.js`** — renders an ASCII **form
wireframe** (tabs, sections, fields with widget hints, the Notes/timeline block, sub-grids, form
JS) from the App Spec, so the user can review a form visually during authoring before approving.
**`scripts/preview-app.js` → `scripts/lib/app-preview.js`** — renders the WHOLE app design
(data model + sitemap tree + views/charts + per-form wireframes + page-intents + design contract)
as a single ASCII preview — the design gate #2 / plan-mode approval artifact.
**`scripts/write-app-spec-doc.js` → `scripts/lib/app-spec-doc.js`** — renders `model-app-plan.md`,
the durable **Markdown design document** the user reviews and keeps alongside the app (jobs →
surfaces traceability, data model, every surface incl. generative pages, navigation, the access
model per role, sample data, design contract, AI features). Distinct from `app-preview.js`: that is
an ASCII console preview for the in-conversation approval gate, this is durable Markdown for review
and archival. It is **rendered, never hand-written** — the freehand version drifted from the spec
and shrank to a counts summary. Also returns `warnings[]` naming design gaps (no jobs captured, a
job with no covering surface, no generative pages) so the orchestrator surfaces them in chat.
- **`scripts/vendor/cds-maker-sdk.cjs`** — the SDK vendored as a self-contained headless bundle
(rebuild via `scripts/_vendor-build/`); **`scripts/lib/sdk-http-client.js`** injects an
`az`-token HttpClient. No browser, no relay — the SDK reuses the designer's own serializers.
- The build log is **phase-grouped with per-step status** (`▶ phase` / `[n/total] ✓ created` /
`⊘ skipped` / `✗ failed`) + a closing summary; dry-run lists the same plan with a `▢` marker.
The end-to-end flow (Phase 0 working dir → Phase 1 author **in the main loop** per
`references/authoring-flow.md` → Phase 2 narrated SDK build → Phase 3 verify & iterate; **edit** an
existing app via the same path — `download-model-app.js` pulls it back into a spec, then re-run Phase 2
idempotently) is diagrammed in [`docs/architecture.md`](docs/architecture.md) → *`/app-builder` —
build pipeline*. **Upcoming:** shippable-defaults provisioning (security role / standard views; the
quick-create table flag now ships via `entities[].quickCreate` / an authored `QuickCreate` form —
auto-generating the Quick Create form's field layout is the remaining follow-up).
## Local Development
Test this plugin locally:
```bash
claude --plugin-dir /path/to/plugins/model-apps
```
## File Tree
The canonical layout of the plugin (architecture **diagrams** live in
[`docs/architecture.md`](docs/architecture.md)):
```
.plugin/plugin.json ← Open Plugins metadata (name, version, keywords)
.mcp.json ← MCP server config (Playwright for browser verification)
AGENTS.md ← Plugin guidance for AI agents (this file)
CLAUDE.md ← Symlink → AGENTS.md
README.md ← User-facing intro and prereqs
CHANGELOG.md ← Keep-a-Changelog
feature-flags.json ← Default-OFF feature flags (currently connectors)
.claude-plugin/plugin.json ← Legacy plugin metadata mirror
docs/
architecture.md ← Wiring/flow diagrams for BOTH skills (/genpage + /app-builder)
app-builder-roadmap.md ← /app-builder roadmap / TODO (Complete + Pending by phase)
app-builder-design.md ← /app-builder design record (Part I staged flow · Part II --changed-only)
agents/ ← Agent definitions (invoked by skills via Task tool)
genpage-planner.md ← Requirements, discovery, plan doc, user approval (create flow)
genpage-connector-builder.md ← Orchestrator-invoked connector gate/discovery; writes connector bindings
genpage-entity-builder.md ← DV entity creation via plugin's Web API scripts (create flow)
genpage-page-builder.md ← Writes one .tsx file; runs in parallel for multi-page (create flow)
genpage-edit-planner.md ← Reads download artifacts, plans edits, writes edit plan (edit flow)
genpage-customapi-builder.md ← Single owner of the custom-api gate; discovers bound Custom APIs, writes ## Custom API Bindings + actions.json (create & edit flows)
references/ ← Shared reference docs
rules.md ← Full code-gen rules, DataAPI types, layout patterns, common errors
custom-api.md ← Dataverse Custom API (Action/Function) invocation contract (loaded when the plan has ## Custom API Bindings)
connectors.md ← GenPage connector binding contract and runtime patterns
plan-schema.md ← Schema contract for genpage-plan.md
data-caching.md ← Rule 15 on-mount fetch: de-dupe + cache (loaded conditionally)
localization.md ← Multi-language + RTL pattern (loaded conditionally)
supported-dependencies.md ← Versioned package list for generated pages
troubleshooting.md ← Deployment/runtime/env issues
verified-icons.txt ← ~5000 Fluent UI icon names; Grep-validated by page-builder
samples/ ← Example .tsx files (13 samples) plus app-builder spec samples
scripts/
launch-playwright-mcp.js ← Playwright MCP server launcher (fullscreen; uses lib/detect-browser.js)
playwright-mcp-fullscreen.config.json ← Fullscreen browser config for the launcher
regenerate-verified-icons.js ← Regenerates references/verified-icons.txt from npm
check-auth.js ← Pre-flight: az present + logged in, pac identity, WhoAmI, identity match
dataverse-request.js ← General Dataverse Web API wrapper (escape hatch)
list-connections.js ← Connector discovery: PAC connections + Dataverse connection references
create-connection-reference.js ← Creates Dataverse connectionreference rows for connector bindings
list-custom-apis.js ← Discovers bindable Custom APIs (Global + entity-bound) + parameter kinds (custom-api gated)
add-page-to-solution.js ← Adds GenPages and optional connection references to a solution
- provision-entities.js ← CLI wrapper for entity provisioning (solution + data-model + sample-data)
+ provision-entities.js ← CLI wrapper for entity provisioning (solution + data-model + sample-data; --language-code)
provision-solution.js ← Creates a Dataverse solution via the SDK
write-page-plan.js ← app-builder Phase 1.5: projects an App Spec into the genpage-plan.md read by page-builder workers
promote-intent-pages.js ← app-builder Phase 1.5: validates every generated page, then atomically flips source: intent → tsx
- build-model-app.js ← app-builder: narrated, idempotent SDK build (dry-run default; --stage data|ui|app|publish; --changed-only)
+ build-model-app.js ← app-builder: narrated, idempotent SDK build (dry-run default; --stage data|ui|app|publish; --changed-only; --language-code)
download-model-app.js ← app-builder: pull a deployed app into an editable spec (edit flow)
teardown-model-app.js ← app-builder: classifier-safe reverse-of-build teardown
verify-model-app.js ← app-builder: reconcile the spec against the deployed app
preview-form.js ← app-builder: ASCII form wireframe for authoring review
preview-app.js ← app-builder: ASCII whole-app design preview (data model + sitemap + forms + page-intents + design)
write-app-spec-doc.js ← app-builder: renders the readable model-app-plan.md design doc from app-spec.json
ai-preflight.js ← app-builder: preflight AI feature availability (admin-gate report)
run-tests.js ← one-command plugin + SDK regression runner
smoke-eval.js ← scripted live smoke eval (build → assert → teardown)
generate-page-manifest.js ← Phase 0.5: writes working-dir package.json + genpage.d.ts
capture-fixture.js ← Copies /genpage working dir into an eval fixture and runs both runners
lib/
entity-provision.js ← Shared entity-provisioning core (solution + data-model + sample-data)
provision-input.js ← Input validation for entity provisioning
dataverse-auth.js ← Shared auth + HTTP helpers (uses `az account get-access-token`)
supported-dependencies.js ← Single source of truth for runtime + dev deps versions
feature-flags.js ← Default-OFF feature flag probe + connector script backstop
sdk-build.js ← app-builder build engine (idempotent; incl. the pages phase)
stages.js ← stage→phase-range mapping + PHASES/STAGES constants
op-diff.js ← destructive-op diff + --allow-destructive / --non-interactive gating
artifact-intent.js ← pure App Spec → canonical SDK intent compiler (new form topology; no SDK calls)
page-plan.js ← pure App Spec → plan-document projection used by write-page-plan.js
source-literals.js ← TSX lexer (code/comment/string/template/regex/JSX) — see "Known limits" below
sdk-teardown.js ← app-builder teardown engine (planTeardown is pure)
sdk-http-client.js ← az-token HttpClient for the vendored SDK
spec-lint.js / app-spec.js ← App Spec guardrail lint + validation
spec-shape.js ← shared structural normalization for both authoring gates
surface-resolver.js ← pure: resolve personas[].jobs[].surfaces[] to the spec artifacts that satisfy them
role-privileges.js ← pure: declared persona privileges + subset comparison against a deployed role
odata.js ← OData literal escaping helpers
genpage-cli.js ← pac model genpage upload/list/download wrapper
hydrate-spec.js ← reconstruct an App Spec from a deployed app (edit flow)
verify-spec.js ← spec-vs-deployed reconciliation core
build-journal.js ← durable JSONL build journal (resume diagnostics)
form-preview.js ← form wireframe renderer
app-preview.js ← whole-app design renderer (data model + sitemap + forms + page-intents + design)
app-spec-doc.js ← pure App Spec → readable Markdown design doc (model-app-plan.md)
schema-facts.js ← pure data-model provisioning fact extractor for evals
pageref-resolver.js ← PAGEREF_<key> → GenPageId nav resolver
page-manifest.js ← durable <app>_pagemanifest read/write
sitemap-pages.js ← pure GenPageId extractors + fail-closed fetchSitemap MEMBERSHIP reader + cross-app scan
ai-candidates.js ← selects good-candidate tables for auto row-summary mode
ai-prompt.js ← generates tailored Copilot row-summary prompts
_graph.js ← entity topological ordering (shared by build + teardown)
system-solutions.js ← built-in system solutions (Active/Default/Basic) — shared by download recovery + teardown skip
phase-diff.js ← pure spec-diff → changed build phases (advisory diff foundation)
content-hash.js / hash.js ← content-aware phase diff: fold on-disk .tsx/contentPath byte hashes into the diff (changed-only)
classify-changes.js ← changed-only: classify a spec diff → fast (page-content) | full | noop + sticky debt
apply-snapshot.js ← changed-only: pure eligibility state machine (identity bind, debt, tombstone, generation CAS)
apply-snapshot-store.js ← changed-only: atomic snapshot write + workspace lease + invalidate/tombstone/delete
apply-snapshot-index.js ← changed-only: build result.created → snapshot artifact map
changed-only-flow.js ← changed-only: --changed-only orchestration (decide fast/full, live identity, snapshot lifecycle)
projection.js ← changed-only: pure post-apply verifiers (form placement / sitemap / page dual-hash)
detect-browser.js ← System Chromium/Edge/Chrome detection (used by the launcher)
modelapps-hook-utils.js ← Tracked-skill discovery + validator lookup for the hooks
telemetry/ ← Bundled 1DS telemetry: ikey.json (this plugin's config) + lib/ (copy of shared/telemetry/lib)
vendor/cds-maker-sdk.cjs ← headless vendored SDK bundle (rebuilt via _vendor-build/)
_vendor-build/ ← esbuild vendoring tooling (build.js + pinned deps)
tests/ ← node --test coverage for the scripts + hooks
hooks/ ← Lifecycle hooks (registered in hooks/hooks.json)
run-skill-posttool-validation.js ← Runs a skill's validate*.js after the Skill tool returns
validate-icon-imports.js ← PostToolUse: blocks unverified @fluentui/react-icons in generated .tsx
validate-write-safety.js ← PreToolUse: flags (non-blocking) out-of-cwd writes in model-apps sessions
run-skill-pretool-telemetry.js ← PreToolUse(Skill): emits skill_started (ships disabled)
run-user-prompt-telemetry.js ← UserPromptSubmit: emits skill_started for /model-apps:<skill>
skills/
app-builder/
SKILL.md ← intent → model-driven app (create + edit); **Preview**
genpage/
SKILL.md ← Orchestrator skill (delegates to agents)
edit-flow.md ← Edit flow steps (loaded only on edit path)
verify-flow.md ← Playwright browser verification (loaded only when user opts in)
report-issue/ ← Bug-report skill (bundled shared workflow)
telemetry/ ← /model-apps:telemetry on|off|status control skill
```
## Skills
| Skill | Description |
|-------|-------------|
| `/genpage` | Build and deploy generative pages for a model-driven Power App |
| `/app-builder` | **(Preview)** Build and edit a whole model-driven app — tables, columns, relationships, adaptive forms, views, Choice-column charts, generative pages, app + sitemap, sample data, and admin-gated AI features — from a natural-language intent, via the vendored `cds-maker-sdk` |
| `/report-issue` | File a bug/issue about the model-apps plugin to the GitHub repository |
| `/telemetry` | Enable, disable, or check usage telemetry (`on \| off \| status`) |
## Agents
Agents are invoked by skills via the `Task` tool — they are not user-invocable.
| Agent | Invoked By | Description |
|-------|-----------|-------------|
| `genpage-planner` | `genpage` (create flow) | Validates prereqs, gathers requirements, detects entity/app existence, presents plan for approval, writes `genpage-plan.md` |
| `genpage-entity-builder` | `genpage` (create flow) | Provisions Dataverse tables, columns, relationships, choices, and sample data via `scripts/provision-entities.js` (the shared SDK-backed core). Bulk inserts use OData `$batch`. Writes a transactional log for recovery |
| `genpage-page-builder` | `genpage` (create flow) **and** `app-builder` (Phase 1.5) | Generates one complete `.tsx` page from a plan document and schema; runs in parallel with other builders for multi-page requests. `/app-builder` projects its App Spec into that plan format via `scripts/write-page-plan.js` and dispatches this same agent |
| `genpage-edit-planner` | `genpage` (edit flow) | Reads the downloaded page artifacts (page.tsx, config.json, prompt.txt), gathers change requirements, presents edit plan, writes `genpage-edit-plan.md`. The orchestrator applies the edit inline. |
| `genpage-connector-builder` | `genpage` orchestrator (create **and** edit flows) | **Single owner of the connectors feature gate.** Performs connector discovery (connections, connection references, datasets, tables, operations, schema), creates Dataverse connection references, and writes the `## Connector Bindings` contract + `connectors.json`. The orchestrator forwards its output into the planner or edit-planner prompt. |
| `genpage-customapi-builder` | `genpage` orchestrator (create **and** edit flows) | **Single owner of the custom-api feature gate.** Discovers the Dataverse Custom APIs a page can bind to (Global + entity-bound Actions/Functions) plus their parameter kinds via `list-custom-apis.js`, and writes the `## Custom API Bindings` contract + `actions.json`. The orchestrator forwards its output into the planner or edit-planner prompt. |
## Key Concepts
### Genux Pages
Generative pages (genux) are React 17 + TypeScript single-file components that run inside model-driven Power Apps. They use Fluent UI V9 for styling and the DataAPI for Dataverse data access. Each page is a single `.tsx` file with `export default GeneratedComponent`.
### DataAPI
The DataAPI (`props.dataApi`) provides typed CRUD operations against Dataverse tables. It uses RuntimeTypes.ts (generated by `pac model genpage generate-types`) for type safety. Column names must be verified from the generated schema — never guessed.
### RuntimeTypes
TypeScript type definitions generated from Dataverse metadata. Contains entity types, enum registrations, and the `GeneratedComponentProps` interface. Generated via PAC CLI before code generation to ensure correct column names.
## Feature Flags
Unreleased functionality is gated behind committed, **default-OFF** feature flags so
the skill can merge ahead of its cross-repo dependencies. With a flag OFF, the
**deployed page behavior is identical to before the feature existed** — the guarantee
is about runtime/deploy output, not that every authoring artifact is byte-for-byte
unchanged (e.g. plans still carry a `## Connector Bindings: No connector bindings.`
line). The mechanism lives in `scripts/lib/feature-flags.js` with the committed
values in `feature-flags.json` at the plugin root.
- **Source of truth:** `feature-flags.json` (e.g. `{ "connectors": false }`). Flip a
flag to `true` in a one-line PR once its dependencies are GA in PROD.
- **Precedence (highest first):** env var `GENPAGE_ENABLE_<FLAG>` (e.g.
`GENPAGE_ENABLE_CONNECTORS=1`) → committed `feature-flags.json` → default `false`
(fail-closed). This mirrors the telemetry opt-out env-over-config convention.
- **LLM gate:** skill/agent markdown probes a flag with
`node "${PLUGIN_ROOT}/scripts/lib/feature-flags.js" <flag>` (prints `enabled`/`disabled`,
exits 0/1) and skips the gated workflow when disabled. `--list` prints every known
flag's lifecycle **status** (experimental / in-progress / ga), effective state +
source (env/file/default), summary, how to enable, plus config-validation warnings.
Flags are catalogued with that metadata in the `FLAGS` map in `feature-flags.js`
(the committed `feature-flags.json` carries only the on/off value).
- **Script backstop:** connector entrypoints call the shared
`exitIfConnectorsDisabled()` helper (DRY — no inlined gate) and fail closed with
exit 3 when OFF: `list-connections.js`, `create-connection-reference.js`, and the
`--connection-refs` branch of `add-page-to-solution.js`. Custom API entrypoints call
the parallel `exitIfCustomApiDisabled()` helper the same way: `list-custom-apis.js`.
- **Validation:** `KNOWN_FLAGS` + `validateFlags()` warn on unknown keys / non-boolean
values in the committed file (so a typo can't silently do nothing, or — after a flip
to `true` — accidentally enable the wrong thing).
**Each gated feature has a SINGLE OWNER agent, and every entry point must go through it or the
shared helper.** Both currently-gated features gate at the same five places, so the rule is stated
once here and only the per-feature specifics are tabled below:
1. **Discovery** — the owner agent runs the probe first; planners/edit-planners delegate to it and
never gate inline.
2. **Scripts** — each entry-point script calls the shared `exitIf<Feature>Disabled()` helper (DRY —
never an inlined gate) and fails closed with exit 3 when OFF.
3. **Deploy** — the SKILL phase **re-probes** the flag and treats an absent/malformed bindings
section as *no bindings*, so a plan authored while the flag was ON cannot deploy after it goes OFF.
4. **ALM** — solution packaging honours the flag (or documents why it needs no gate).
5. **Codegen** — `genpage-page-builder` emits feature code **only** when the plan carries an actual
binding table, never on an absent/sentinel section.
| | `connectors` | `custom-api` |
|---|---|---|
| **Owner agent** | `genpage-connector-builder` | `genpage-customapi-builder` |
| **Plan section** | `## Connector Bindings` | `## Custom API Bindings` |
| **Gated scripts** | `list-connections.js`, `create-connection-reference.js` | `list-custom-apis.js` |
| **Deploy phase** | SKILL Phase 4.5 | SKILL Phase 4.6 |
| **ALM** | the `--connection-refs` branch of `add-page-to-solution.js` | none needed — `config.json`'s `actionBindings` travels inside the page's `uxagentprojectfile` rows automatically (the Custom APIs themselves are a separate deployment prerequisite, bound by name) |
| **Emits** | connector code | `executeAction` / `executeFunction` / `listBoundActions` |
One connectors-only nuance: at Phase 4.5 the `/genpage` orchestrator re-probes and passes the
verbatim result as `Connectors: enabled|disabled` in every page-builder dispatch — **that dispatch
value wins over the plan's `## Connector Bindings` section.**
Both flags currently ship **OFF**, each waiting on cross-repo dependencies:
- **`connectors`** — the pac CLI connector verbs (PowerPlatform-Scale-AdminTools), the GenUX
authoring control (power-platform-ux), and the maker/admin ECS setting must all release first.
- **`custom-api`** — the AIBuilder CoderAgent action prompt, the shared `pai-gen-ux-action-runtime`
plus the UCI and Controls host runtimes, a pac CLI `model genpage upload --actions` verb to
persist `actionBindings` into `config.json`, and the `GenUxPluginActionAllowList` ECS setting.
Note the maker-facing name is "Custom API" while the shipped wire contract stays
`actionBindings` / `executeAction` (see `references/custom-api.md`).
## TSX source lexer — known limits
**`scripts/lib/source-literals.js` — known limits.** It is a hand-rolled TSX lexer, not a
parser: the plugin ships dependency-free, so there is no TypeScript to call. It tracks
code / line comment / block comment / string / template / regex / JSX tag / JSX text, and
backs the `promote-intent-pages.js` structural gate plus the eval's effect scoper.
Judge changes to it by **both** error directions, and weight them correctly:
- a false **accept** promotes prose as a page, and promotion is sticky — the page is then
marked implemented and skipped on retry;
- a false **reject** blocks a real user mid-build, which is worse, because their page was
fine.
`scripts/tests/source-literals.test.js` carries a **false-positive corpus** over every
committed `.tsx` in `samples/` and `evals/model-apps/genpage/fixtures/` (enumerated via
`git ls-files`, so it does not race the transient fixture dirs `capture-fixture.test.js`
creates). Two lexer bugs were invisible to hand-written cases and caught only by that
corpus — keep it, and add to it rather than around it.
Residual limits, accepted deliberately:
- **`<` disambiguation is structural, not semantic.** A generic arrow is recognised by its
shape — type parameters, then a balanced `(…)`, then `=>` (optionally via a return-type
annotation). Exotic shapes that break the shape rule (e.g. a type-parameter *default*
containing an unmatched `)`) can still be misread.
- **Lookahead is bounded** (`LOOKAHEAD`, 2000 chars) so a stray `<` cannot walk the file. A
signature longer than that is not recognised as a generic.
Neither shape occurs in the corpus, and both fail *loudly* (exit 3, retryable) rather than
silently. If you hit one, widen the tests first.
## Hooks & Validators
Hooks are registered centrally in `hooks/hooks.json` (auto-loaded by the plugin
host). Validators **fail open** on any internal error (exit 0). The icon validator
blocks only on a real generated-page violation (exit 2); the write-safety guard is
non-blocking by design (exit 1), so hook bugs do not break `/genpage` or
`/app-builder` authoring.
- **PostToolUse(Skill)** — `run-skill-posttool-validation.js` runs a skill's
`skills/<skill>/scripts/validate*.js` when present. Tracked skills are discovered
from `skills/*/SKILL.md` by `scripts/lib/modelapps-hook-utils.js`, so both
`/genpage` and `/app-builder` are tracked automatically (the telemetry control
skill is excluded from tracking).
- **PostToolUse(Write|Edit|MultiEdit)** — `validate-icon-imports.js` validates
`@fluentui/react-icons` named imports in generated `.tsx` pages against
`references/verified-icons.txt`, automating the page-builder's manual grep. It is
gated to generated-page output (the file's `export default GeneratedComponent`
marker) or plugin-specific sibling markers (`genpage-plan.md` or
`model-app-plan.md`). It intentionally does **not** use `app-spec.json` as a
sibling marker because this validator blocks (exit 2) and `app-spec.json` is too
generic for a globally installed hook.
- **PreToolUse(Write|Edit|MultiEdit)** — `validate-write-safety.js` **flags
(non-blocking, exit 1)** writes outside the cwd, and only during an active
model-apps authoring session (`genpage-plan.md`, `model-app-plan.md`, or
`app-spec.json` at/under cwd). It accepts `app-spec.json` because a false
positive only warns; it never blocks and is a clean no-op in unrelated projects.
Silence with `MODEL_APPS_SKIP_WRITE_GUARD=1`.
- **Master kill-switch** — `MODEL_APPS_DISABLE_HOOKS=1` (or `true`) disables **all**
model-apps hooks (validators + telemetry emit); checked before any stdin/work.
Both escape hatches are documented in `README.md`.
## Telemetry
This plugin ships 1DS telemetry for `skill_started`. The canonical library is the
repo-root `shared/telemetry/`; `scripts/lib/telemetry/lib` is a **physical copy**
(never a symlink) so installed plugins don't depend on symlink handling. Edit
`shared/telemetry/lib/` first, then refresh this plugin's copy in the same change.
- **Posture:** the committed `ikey.json` ships **`disabled: true`** (Tier-1 static,
no resolver) — currently carrying the **provisioned model-apps key + collector +
`event_stream_name`, staged disabled**. It emits nothing — no POST, no local log —
while `disabled: true`; flip `disabled` to `false` only after the Geneva mapping
is validated in DGrep (see the ADE provisioning runbook). `disabled: true` is the
active guard; the placeholder-key gate is a secondary defense for un-provisioned
copies. **Provision a fresh key; never copy another plugin's `ikey.json`**
(CI-enforced: `node scripts/validate-telemetry-ikeys.js`).
- **Emission:** `hooks/run-skill-pretool-telemetry.js` (PreToolUse Skill) and
`hooks/run-user-prompt-telemetry.js` (UserPromptSubmit `/model-apps:<skill>`).
- **Privacy:** default-on usage telemetry. Events include Dataverse organization
and Entra tenant GUIDs when PAC is signed in, but Model Apps excludes the
signed-in user's Entra object ID. The local diagnostic mirror retains the same
event fields. Users opt out of transmission via
`/model-apps:telemetry off`; the local diagnostic mirror
(`~/.power-platform-skills/telemetry/model-apps/sessions/<id>/events.jsonl`) is
still written. CI/automation opt out via
`POWER_PLATFORM_SKILLS_TELEMETRY_MODEL_APPS_OPTOUT=1` (highest precedence).
- **Fail closed:** telemetry never changes a script's exit code; emission is
fire-and-forget via a detached dispatcher child. See `shared/telemetry/README.md`.
## Development Standards
- **React 17 + TypeScript** — all generated code
- **Fluent UI V9** — `@fluentui/react-components` exclusively (DatePicker from `@fluentui/react-datepicker-compat`, TimePicker from `@fluentui/react-timepicker-compat`)
- **Single file architecture** — all components, utilities, styles in one `.tsx` file
- **No external libraries** — only React, Fluent UI V9, approved Fluent icons, D3.js for charts
- **Type-safe DataAPI** — use RuntimeTypes when Dataverse entities are involved
- **Responsive design** — flexbox, relative units, never `100vh`/`100vw`
- **Accessibility** — WCAG AA, ARIA labels, keyboard navigation, semantic HTML
- **Complete code** — no placeholders, TODOs, or ellipses in final output
## Dataverse Access From Scripts
**Default: go through the vendored SDK.** Anything the SDK models — tables, columns, relationships,
views, charts, forms, commands, dashboards, app modules, sitemaps, solutions, roles, settings — is
read and written through `createMakerSdk`. That is not style: the SDK persists metadata under
`<app-folder>/.maker-workspace/` for reuse and edits, resolves artifact identity the same way the
build does, and owns retry/pagination behaviour. A read that bypasses it can disagree with the write
about which artifact it is talking about.
Two escape hatches exist, and both are deliberate. The maker SDK models the *maker* surface; parts of
Dataverse simply are not in it.
| Hatch | Use for | Examples in tree |
|---|---|---|
| `dataverseRequest()` in `lib/dataverse-auth.js` (and the `dataverse-request.js` CLI) | Dataverse surfaces the SDK does not model at all | `WhoAmI` (`check-auth.js`), `customapis` (`list-custom-apis.js`), `connectionreferences` (`create-connection-reference.js`), solution-component adds (`add-page-to-solution.js`) |
| The raw `httpClient` from `createAzHttpClient` | A surface the SDK *does* touch but whose response it **projects away** | `entityPrivileges` in `verify-model-app.js` — `fetchEntityMetadata` returns `{logicalName, displayName, entitySetName, attributes, relationships}` and drops `Privileges` entirely. The projection's omission is permanent (it is disk-cached and best-effort, the wrong contract for a security read) and pinned by an SDK guardrail test. **Transitional:** the SDK is gaining a dedicated `getEntityPrivileges()`; switch to it and drop this raw read once the vendored bundle carries it |
**Prefer `dataverseRequest()` over the raw client.** It already handles the API path, auth, headers
and timeouts. Reach for `httpClient` only when you must share the exact client instance the SDK is
using, as the verify reader does.
When you do go direct, all four of these apply:
1. **Comment WHY the SDK cannot serve it** — name the SDK method you would otherwise call and what it
drops or lacks. "Deliberately not `sdk.fetchEntityMetadata`" is the difference between a
documented exception and something a later reader "simplifies" back into a silent bug.
2. **Absolute URL including `/api/data/v9.2`** when using the raw `httpClient`. It is the transport
the SDK drives, so it takes full request URLs and validates them with `new URL(url)` for its
same-origin guard — a relative path throws there rather than resolving against the org.
3. **GUIDs unquoted.** Record ids and `_x_value` lookups are `Edm.Guid`; `id eq '<guid>'` fails with
*"A binary operator with incompatible types was detected"*. See `references/troubleshooting.md`.
4. **Test the reader itself, not only an injected stub.** The `entityPrivileges` URL bug shipped
because every test injected a fake reader into `verifySpec`, so the real one was never executed —
and `verify-spec` catches per-entity read failures, so it would have failed silently on every live
run rather than crashing. Drive at least one test through the real client's request seam.
- Keep SKILL.md under 500 lines
- Use short, descriptive `name` field (e.g., `genpage`)
- Write descriptions in third person ("Creates X" not "This skill guides you through creating X")
- Use progressive disclosure: SKILL.md for workflow, reference files for details
- Link to references inline: `See [troubleshooting.md](../../references/troubleshooting.md)`
- Immediately after the frontmatter of every user-invocable skill, run
`node "${PLUGIN_ROOT}/scripts/check-version.js"` and show any output before
proceeding. The check is best-effort and must never block skill execution.
## Building & Testing
**One-command regression gate (run before every commit)** — from `plugins/model-apps/`:
```bash
# Plugin unit suite only (node:test):
node scripts/run-tests.js
# Plugin suite + the vendored SDK's Jest suite (Node 20):
NODE20_BIN=/path/to/node20/bin node scripts/run-tests.js --with-sdk /path/to/power-platform-ux
```
- `run-tests.js` runs the full `scripts/tests/*.test.js` suite and prints a combined PASS/FAIL.
**CI runs this same command** (`.github/workflows/model-apps-script-tests.yml`) on any PR touching
`plugins/model-apps/**` or `evals/model-apps/**`, across ubuntu × windows × macos and Node 20 × 22.
Keep `POWER_PLATFORM_SKILLS_TELEMETRY_MODEL_APPS_OPTOUT: "1"` on any new job that could run a
telemetry-emitting hook or script.
- The SDK's Jest suite needs **Node 20** (its `canvas` native module is built for the Node-20 ABI).
Set `NODE20_BIN` to a Node-20 bin dir; without it the SDK suite is skipped (plugin suite still runs).
- genpage evals: `node --test evals/model-apps/genpage/tests/*.test.js`, plus the Layer 1/2 runners
(`node evals/model-apps/genpage/run-layer-{1,2}.js --tier smoke`). See `## Eval Suite` below.
**The vendored SDK lives in a separate repo.** The Dataverse mechanics are in
`power-platform-ux` (Azure DevOps `msazure/OneAgile`), package `packages/cds-maker-sdk`. This plugin
ships a **self-contained bundle** at `scripts/vendor/cds-maker-sdk.cjs` (NOT the SDK source). The SDK
owns deterministic wire formats (create/query/delete, AI settings/row-summaries, `seedRecordGraph`,
`enrichDefaultViews`, artifact resolve/cascade); the plugin owns judgment (spec validation, candidate
selection, prompt authoring). To change SDK behavior:
```bash
# 1. Build the SDK (emits lib/) — from <ppux>/packages/cds-maker-sdk:
npm run build # tsc/ppux-build
npm test # Jest (Node 20)
npm run lint # ppux-lint (Node 20)
# 2. Rebuild the vendored bundle here (reads the SDK's lib/, so build the SDK first) — from repo root:
node plugins/model-apps/scripts/_vendor-build/build.js --sdk /path/to/power-platform-ux
# → rewrites scripts/vendor/cds-maker-sdk.cjs (~540 KB). COMMIT the rebuilt bundle.
```
Only the SDK `src/` is committed in the SDK repo (`lib/` is gitignored). A type-only/whitespace SDK
edit produces a byte-identical `lib/*.js`, so the bundle only needs rebuilding when SDK **runtime**
changes.
**Vendored-SDK contract invariants (regression net).** When you bump the SDK and re-vendor, the
skill relies on behaviors that must survive. Three test files lock them — run all against every
rebuilt bundle:
`scripts/tests/sdk-surface-contract.test.js` — the **method-presence** guard. Asserts every SDK
method the engines call (`SKILL_SDK_SURFACE`, kept in sync with the `provision.*` / `sdk.*` call
sites by a source-scan test that also covers `artifact-intent.js`) is a function on the real vendored
bundle. A re-vendored SDK that **renames or removes** a method the skill uses fails HERE, listing the
exact names — instead of silently at build time (the mock-based `sdk-build`/`sdk-teardown` suites
can't catch that, since the mock mimics the old interface). The skill drives Dataverse through the
SDK's **generic** surface (`createArtifact`/`addElement`/`updateElement`/`removeElement`/`getArtifact`/
`fetchArtifact`/`pushArtifact`), NOT per-artifact mutators — a bundle that drops the generic surface
fails here. Update `SKILL_SDK_SURFACE` **and** migrate the call sites together.
`scripts/tests/hardening2-real-bundle.test.js` — **compiler↔adapter integration** against the real
bundle: it drives `artifact-intent.js` + the generic surface exactly as the engine does and asserts
the real wire output — **parity** with a pre-swap golden (`fixtures/parity-golden.json`, via the pure
`wire-facts.js` normalizer), multi-tab/section create, **metadata-derived control classIds** (a Lookup
and a String field get DIFFERENT classids — the adapter defaults them from attribute type, T4, so the
plugin must NOT precompute classId), sub-grid relationship/target/view serialization, `/bag/c` events
**merge** (exactly one `<events>` root on a rebuild), field removal, and the **412 → failed
`PushResult`** signal `requireSuccessfulPush` halts on. The mock-based `sdk-build.test.js` covers the
engine ORCHESTRATION (call order, idempotency, phase selection); this covers what a mock cannot.
`scripts/tests/vendor-sdk-smoke.test.js` — the **behavior/return-shape** `CONTRACT:` tests (drive the
public `createMakerSdk` factory — the `MakerSdk`/`AppAdapter` classes are no longer bundle exports):
- **Raw OData filters pass through, single-encoded** — the skill builds raw `$filter` strings
(quoted string literals via `lib/odata.js`, and **unquoted GUID literals** like `objectid eq <guid>`);
a query builder may transport-encode them but must not double-encode.
- **Name-based methods accept logical/unique/schema names verbatim** — `deleteTable`, `setEntityIcon`,
`resolveArtifact({uniqueName}/{name}/{entity})`, `createRelationship({referencedEntity,…})`. A GUID
normalizer must apply to GUID params ONLY, never to these names.
- **Sitemap free-text (titles/URLs/descriptions) is XML-escaped, not rejected** — a "safe DOM factory"
must escape attribute/text VALUES while only validating element/attribute NAMES.
- **`deleteAppCascade` returns a structured `{ success, deleted, failures }` result** — teardown
reads `failures` to report orphaned sitemap/genpage rows instead of claiming a clean delete; the
bundle must keep returning the result (not void) after a re-vendor.
- **`seedRecordGraph` returns `{ createdIds: { <entityLogical>: [ids] } }`** and dedups only on an
explicit **`matchOn`** key (it NEVER falls back to the primary display name — `buildSeedGroup`
supplies `matchOn` from a single-column alternate key or the primary name, validated non-empty).
**Live end-to-end (app-builder — writes to a real Dataverse env; optional).** All build/verify/
teardown scripts are **dry-run by default**; add `--apply` to write.
```bash
az account set --subscription <sub-id>
node scripts/check-auth.js --env <envUrl> # az token + WhoAmI preflight (pac optional; --require-pac for genpage)
node scripts/build-model-app.js --env <envUrl> --spec @<dir>/app-spec.json [--sample-data --publish] --apply --verify
node scripts/verify-model-app.js --env <envUrl> --spec @<dir>/app-spec.json
node scripts/teardown-model-app.js --env <envUrl> --spec @<dir>/app-spec.json --apply
```
AI features are **admin-gated** — preflight readiness with `node scripts/ai-preflight.js --env <envUrl>`.
Prefer a scratch env; always tear down probes (`teardown-model-app.js --apply`) to leave 0 leftovers.
**After modifying the plugin also:** run `claude --debug` to confirm the plugin loads, exercise the
skill (`/genpage` or `/app-builder`), and for genpage verify Playwright browser checks
(navigate/snapshot/click/screenshot).
**Hooks + telemetry:** the lifecycle hooks and telemetry hooks are covered by the plugin unit suite
(`node scripts/run-tests.js`). Keep `scripts/lib/telemetry/ikey.json` shipping `disabled: true` until a
key is provisioned (a test enforces this), and run `node scripts/validate-telemetry-ikeys.js` from the
repo root after touching `ikey.json`.
## Eval Suite
The plugin has a 3-layer eval suite under `evals/model-apps/genpage/`. Two
layers are automated (TAP v13 runners); Layer 3 is manual.
- **Comprehensive guide:** `evals/model-apps/genpage/EVAL_GUIDE.md` — what
we evaluate, the 3 layers, tiers (smoke/full/stress), fixture types
(synthetic vs real captures), runner output, capture flow, cadence,
diagnosing failures, adding evals and assertions.
- **Eval definitions:** `evals/model-apps/genpage/evals.json` — 18 evals
with prompts, answers, and expectations.
- **Fixtures:** `evals/model-apps/genpage/fixtures/<eval-id>-<slug>/` —
one folder per captured or synthetic run. Each contains the `.tsx`,
`workflow-log.md`, `genpage-plan.md`, and (when applicable)
`entity-creation-log.md` and `RuntimeTypes.ts`.
Run on every PR that touches the skill, agents, rules, or evals:
```bash
node evals/model-apps/genpage/run-layer-1.js --tier smoke
node evals/model-apps/genpage/run-layer-2.js --tier smoke
```
### /app-builder — offline structural harness
A data-driven, **offline** eval harness at `evals/model-apps/app-builder/`
(sibling of `genpage/`). Grades **structural per-stage facts** — not `.tsx`
snapshots — using the plugin's own pure primitives. No live env required.
- `evals.json` + `fixtures/<n>-<slug>/app-spec.json` — data-driven cases
- `lib/facts.js` — per-stage fact computation (`schema-facts.js` + `app-spec.js` primitives)
- `lib/assertions.js` — assertion text → check function registry
- `run-app-builder.js` — TAP v13 runner (run from the repo root)
- `EVAL_GUIDE.md` — grading guide (see [`evals/model-apps/app-builder/EVAL_GUIDE.md`](../../evals/model-apps/app-builder/EVAL_GUIDE.md))
Per-stage oracles: `author` (validate + lint), `plan` (`planFor`), `data`
(`schema-facts.js` normalized tables/columns/relationships), `ui` (view/chart/form
intent facts), `app` (sitemap facts + nav graph), `verify` (`verifySpec` reconcile).
```bash
# From repo root:
node evals/model-apps/app-builder/run-app-builder.js
```