app-builder
SkillAI & models(Preview) Builds and edits a model-driven Power Apps app from a natural-language intent — tables, columns, relationships, adaptive forms with sub-grids, views, Choice-column charts, business rules, business process flows, generative page intents for overview/dashboard surfaces (page `.tsx` generated in generate-pages after plan approval), and an app module + sitemap — via the headless cds-maker-sdk. Runs an interactive, multi-turn authoring flow (env selection, jobs-to-be-done first, then design-only App Spec authoring across confirmed levels, guardrail lint, plan-mode approval, generate-pages, full build) and a narrated build, and can download a deployed app back into an editable spec to change it. Use when the user says "build an app for X", "create a model-driven app", "make me an app to manage Y", "add a business process flow", or "edit/add to my app". This skill stands alone and does not require /genpage — but for a standalone generative page added to an app that already exists, use /genpage instead.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the app-builder skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/model-apps/skills/app-builder/SKILL.md and read by ahel’s review.
Plugin check: Run
node "${PLUGIN_ROOT}/scripts/check-version.js"— if it outputs a message, show it to the user before proceeding.
app-builder — intent → model-driven app
⚠️ Preview. This skill is in preview — its App Spec shape, flags, and build behavior may change between versions. Review the plan-mode summary before applying, and prefer a non-production environment while it stabilizes.
Turn a natural-language intent into a deployed model-driven app. You author a reviewable App Spec
(JSON) with the user across confirmed turns, then a deterministic engine (cds-maker-sdk, vendored)
builds it — tables/columns/relationships, sample data, views, Choice-column charts, adaptive forms
with sub-grids, generative pages for overview/dashboard surfaces, and the app module + sitemap.
The same spec drives create and edit: download a deployed app back into a spec, change it, and
re-run the build (it's idempotent).
CRITICAL — run the interactive flow in THIS conversation (the main loop)
You MUST run the authoring questions and the build narration yourself, in the main conversation. Do NOT dispatch a subagent (
Task) for the interactive steps.A subagent is headless —
AskUserQuestionand plan mode do not reach the user from inside one (its only output is its final message). The whole point of this skill is the multi-turn, propose-then-confirm experience, so everyAskUserQuestion,EnterPlanMode, and live build status line must originate here, in the main loop.
CRITICAL — the user sees your chat message, NOT tool output
Shell/tool output — the result of running
preview-app.js, a dry-run plan, or a lint — is COLLAPSED BY DEFAULT in the UI. The user does NOT see it unless they manually expand the tool panel. Running the command is therefore NOT the same as showing the user. Every artifact the user must read, review, or approve — the whole-app preview wireframes, the dry-run build plan, and blocking lint findings — MUST be reproduced verbatim in your chat reply, inside a fenced```code block. Never say "the preview looks right" and leave the content buried in a collapsed panel: paste it into your message. This is the #1 cause of "the wireframes aren't visible" — the preview ran, but its output stayed hidden.
Capabilities — the full toolbox (pick best-fit per requirement)
You are a complete model-driven app builder, not a single-surface tool. Everything below ships in one App Spec and one build — choose what best serves the user's requirement to make a useful, prod-ready app; don't under-build (a bare table list) or over-build (surfaces nobody asked for):
- Data model — tables (give each custom table a meaningful Fluent-style SVG table icon by default; propose what the glyph will depict in words — never a Fluent token name — and record it as
iconDescriptionbefore drawing the SVG — seereferences/authoring-flow.md→ Table icons), columns (all types), relationships (1:N / N:N + junctions), sample data - Record UI — forms (sub-grids, quick-create / quick-view), views (with enriched default columns), charts
- Custom grid rendering (preview) —
entities[].columns[].visualization: render a column as aRadialDial,LineChart,HeatMaporStarRatingin every grid and view that shows it, instead of plain text. Reach for it when a column is a magnitude a user scans (a score, a utilization %, a rating, a priority) rather than a value they read exactly — it makes a list scannable at a glance for one line of spec. It is per-column, so set it once on the column, not on each view. Preview: on an environment where it is not provisioned the build skips it and everything else still deploys, so it is always safe to include. - Actions — modern command-bar buttons (incl. flyout / split menus), web resources (form JS / HTML / CSS).
Two rules when writing that JS, both learned from buttons that deployed perfectly and then did
nothing: a command handler is handed the record (
function doThing(primaryControl)) — the build supplies the parameter, so write that signature; and never hardcode Choice values like100000003, because they are assigned per publisher. Resolve by label viagetOptions()(seereferences/app-spec-schema.md→ webResources). Note also that command and web-resource edits do not redeploy on rebuild — the phases reuse what exists, so changing a button or a script means deleting it first. - Form logic without code —
businessRules[]: show/hide, lock/unlock, set-required and set-value, driven by a condition on the record. Reach for a business rule before form JS when the requirement is field-level and declarative — it is visible in the maker, survives solution export, and needs no web resource. Use form JS when the logic needs a real API call, cross-record work, or anything beyond the four supported actions. - Guided processes —
businessProcessFlows[]: the staged bar across the top of a record (ordered stages, each with steps bound to that table's columns — every step must bind afield; the platform rejects one without, so use a Boolean flag for a manual check-off). Reach for one when the user describes work moving through phases — "triage → investigate → resolve", "lead → qualify → close". Author itActive(the default) or the stage bar does not appear at all. v1 is single-entity and linear: cross-entity stages, branching, stage actions and security-role grants are rejected by the spec gate, so offer Maker for those rather than writing them into the spec. - Surfaces — generative pages (modern dashboards / overviews / analytics / landing — the default), classic dashboards (opt-in), external URLs
- App shell — the app module + sitemap, with per-subarea icons. Turn on the modern shell with
app.newLook: trueunless the user asks for the classic one; it is opt-in and best-effort, so a tenant without the setting still gets a working app.app.headerNavigationRefreshcontrols the Wave 2 header/navigation refresh — a separate, independent setting whose platform default is ON, so set it tofalseonly when the user explicitly wants the classic header. - Security & access — one security role per persona, sized from that persona's jobs-to-be-done (the entity access each job needs, unioned into the role), so the app opens for non-admins.
- AI-first features (admin-gated) — form-fill assist, natural-language grid/view search, NL chart / AI data visualization, M365 Copilot (opt-in); per-table Copilot row summaries (Insight Cards) with tailored prompts, auto-selected for good-candidate tables
Author the smallest spec that fully satisfies the ask, then let the user refine. The Genpage-first
policy below is the record-vs-dashboard rule; references/app-spec-schema.md
documents every field.
Genpage-first policy (surface classification)
Every app surface is one of two kinds — enumerate the app's surfaces and classify each one, don't decide page-by-page in passing:
- Record surfaces (create/read/update/list a table's rows) → a model-driven form + view.
- Everything else — overview/landing, dashboard, KPIs, analytics, guided or wizard flow,
composite or comparison screen → a generative page (
pages[]), not a classic dashboard.
Rules:
- An app whose jobs include an overview, a queue, analytics or a guided flow but which proposes no pages has missed a surface. Where the app genuinely is record-CRUD only, say so explicitly rather than silently omitting pages.
- A traditional
dashboards[]is emitted only on explicit request (e.g. "use a classic dashboard"). - A generative page is authored as a design intent (
source: { kind: "intent" },schemaVersion: 2) during Phase 1 — its.tsxis written in Phase 1.5 — Generate pages, after plan approval and after the data pre-build creates the tables sopac model genpage generate-typescan emitRuntimeTypes.ts. Seereferences/authoring-flow.md→ Pages. - The build's
pagesphase uploads each page viapac model genpage uploadwithout--add-to-sitemap— the SDK is the single sitemap writer, so a page's nav entry comes from apagesubarea inappShell(referenced by the page'skey). Seereferences/app-spec-schema.md→pages[]. - Every page in
pages[]must be sitemap-placed — validation rejects any page absent from the sitemap. A "detail" page that receives a caller-supplied id is a normal sitemap page; it reads its input viapageInput?.data?.<field>. Navigation-only (headless) pages are not supported. - A page that declares
pageInputMUST declaredirectEntry. Because every page is sitemap-placed, a detail page is also reachable straight from the app navigation with no input — a state a user reaches by clicking. Say what happens then:{ "behavior": "selector" }shows a picker and then the record,{ "behavior": "emptyState" }explains and renders nothing broken. Preferselectorwhen the table is browsable; it is the more useful landing. Every key inpageInput.datamust also be produced by some page'snavigatesTo[].data, or the generated page reads a key nothing ever sets. - Three-authority page identity (build + download + verify all follow this): (1) IDENTITY —
the durable
<app>_pagemanifest(key → pageId); a downloaded spec's ownpages[].pageIdoutranks it for that rebuild. (2) EXISTENCE — env-widepac model genpage list(crash-safe; decides create-vs-reuse). (3) MEMBERSHIP — the app's sitemapGenPageIdset (placement, download enumeration, verify coverage). All matching is by id — never by display name. - Multi-page navigation uses
PAGEREF_<key>(the stablepages[].key) as thepageIdplaceholder; the build resolves each to the real page GUID in a run-scoped staging copy (the canonical.tsxis never mutated), then verifies every nav edge resolves to the deployed page'sGenPageId.
Workflow
Phase 0 — Working directory
- Derive a short kebab-case slug from
$ARGUMENTS(e.g. "Project Tracker" →project-tracker). - Create the directory (
mkdir -p <slug>on bash/PowerShell;mkdir <slug>on cmd, which has no-pand errors if it exists) and resolve its absolute path. It holdsapp-spec.json,model-app-plan.md, andworkflow-log.md.
Phase 1 — Author the App Spec (interactive, main loop)
Follow references/authoring-flow.md step by step, running
every prompt yourself via AskUserQuestion. In short:
- Prereqs —
node --version,pac help(≥ 2.7.0). - Environment (PAC) —
pac auth list. If exactly one / an active profile, confirm it (FYI), don't ask. If several and none active, ask which to use. If none, ask the user topac auth create. Capture the org URL (pac org who). - Detect existing —
pac model list-tables --search …(exact-match) andpac model listto find tables/apps already present; build around them. - Levelled authoring — first read the App Spec format so you author to the exact
shape (do this once; don't go spelunking through scripts):
references/app-spec-schema.mdand the worked samplesamples/app-spec.support-desk.json. Phase 1 is design-only: never emit page.tsxhere. Each level is confirmed viaAskUserQuestionbefore the next begins, andapp-spec.jsonis persisted after each — full prompts in the playbook.- Level (a0) — personas & jobs-to-be-done (
personas[]): before proposing any tables, ask who will use the app and what each needs to get done. Jobs drive everything that follows — a table exists because a job needs its data, a surface because a job needs to act on it. Deriving the data model first reliably misses surfaces. Privileges come later; capture the jobs now. - Level (a) — data model: entities/columns/relationships derived from those jobs; run the early data-model lint (catches e.g. the relationship-vs-lookup collision before forms are authored on top).
- Descriptions are part of authoring, not a cleanup pass. Every table, column, view, chart,
form, dashboard and business rule takes an optional
description, and you should write one as you create the artifact — never as a backfill. A name says what a thing is called; a description says what it is for, and it is the only intent an app carries that an agent can read back later when it inspects an app it did not build. Describe the purpose, not the shape:"Severity 1-5; drives the escalation rule and the SLA clock", not"The priority column". (commands[]andCustomercolumns accept one but the SDK cannot write it — you'll get a warning;personas[]does not take one at all. See the schema reference for why.) - Level (b) — artifacts + page-intents + design: enumerate every surface each job needs and
classify it per the genpage-first policy above — record CRUD → form + view; anything else
(overview/landing, dashboard, KPIs, analytics, guided/wizard flow, composite or comparison
screen) → a page intent (
source: { kind: "intent" }). State each classification out loud; if the app needs no pages, say so and why rather than silently omitting them. Then forms + views- charts + sample data and the optional
designcontract, mapping each job to its surfaces viajobs[].surfaces[]. No page.tsxhere.dashboards[]only on explicit request.
- charts + sample data and the optional
- Level (c) — access (
personas[].jobs[].privileges[]): personas and jobs already exist from (a0) and the entities now exist, so only add the privileges each job needs — don't re-ask who the users are. The builder unions them into one role per persona and grants the app to it so it opens for that persona, not just sysadmins. Render the roles + per-entity access as a table in your chat reply (the user can't approve an access model they can't see — see the CRITICAL note above). If they want no roles, tell them jobs live inpersonas[]so dropping the roles drops the recorded jobs too; offer read-only privileges to keep the record. (Column-level security and access teams are not yet supported — see Notes & limits.) - Whole-app preview (design gate for Level (b)):
node "${PLUGIN_ROOT}/scripts/preview-app.js" --spec @<working-dir>/app-spec.jsonrenders data-model + sitemap + form wireframes + page-intents + design contract. Reproduce the ENTIRE rendered output verbatim in your chat reply, inside a fenced```code block — do NOT leave it in the (collapsed, invisible) tool output, and do NOT just summarize "the preview looks right" (see the CRITICAL note above). The user must be able to SEE each form, the sitemap, and the page intents they are approving. For a single form only:node "${PLUGIN_ROOT}/scripts/preview-form.js" --spec @<working-dir>/app-spec.json. - Don't pre-create tables/columns — the build does it idempotently.
- Level (a0) — personas & jobs-to-be-done (
- Guardrail lint (hard gate) — run the full
spec-lint.json the complete spec; errors block, warnings teach. If it blocks (or warns), paste the findings into your chat reply — tool output is collapsed and invisible to the user (see the CRITICAL note above), so the user can't fix what they can't see:node -e "const{lintAppSpec}=require('${PLUGIN_ROOT}/scripts/lib/spec-lint.js');const s=require('<working-dir>/app-spec.json');const r=lintAppSpec(s);console.log(JSON.stringify(r,null,2));process.exit(r.ok?0:1)" - Plan-mode approval (the single build approval) — present the plan including the build
dry-run's phase-grouped plan (run
build-model-app.jswithout--apply, using theplanprofile that allows intent pages) insideEnterPlanMode, thenExitPlanModeto get the user's go-ahead. On approval, Phase 1.5 (generate-pages) runs first, then Phase 2 applies directly (no second dry-run/go-ahead). Render the design document — never hand-write it:
It writesnode "${PLUGIN_ROOT}/scripts/write-app-spec-doc.js" --spec @<working-dir>/app-spec.json --env <envUrl><working-dir>/model-app-plan.md(jobs → surfaces traceability, data model, every surface, navigation, access model, sample data) and prints{ ok, docPath, bytes, warnings }. Surface thosewarnings— they name design gaps such as a job with no covering surface or an app with no generative pages. Tell the user where the document is; it's theirs to keep, and it's regenerable after any spec edit.
Phase 1.5 — Generate pages (main loop, headless workers)
After plan-mode approval (before the full build):
-
Data pre-build — schema-only build so
generate-typescan resolve real column names:node "${PLUGIN_ROOT}/scripts/build-model-app.js" \ --env <envUrl> --spec @<working-dir>/app-spec.json --stage data --apply--stage dataapplies solution + data-model only — no--sample-data(rows are created once in the full build). Only--stage datais apply-safe; all other--stageselectors and legacy--from/--to/--only/--skipselectors are dry-run inspection only. -
Types — generate Dataverse type bindings for the entities the pages read:
pac model genpage generate-types --data-sources "<entity1,entity2,…>" --output-file <working-dir>/RuntimeTypes.ts--data-sourcesis the union of every intent page'sdataSources. Skip this step entirely when every intent page is mock-only. On Windows use forward slashes in the path. -
Page plan (adapter) — the page worker's input contract is a plan document, not an App Spec, so project the spec into one. This also echoes the per-page dispatch parameters:
node "${PLUGIN_ROOT}/scripts/write-page-plan.js" \ --spec @<working-dir>/app-spec.json --working-dir <working-dir> --env <envUrl> \ --app "<app name>" --languages "<languages from the environment probe>"It writes
<working-dir>/app-builder-page-plan.mdand prints{ ok, planPath, pages: [{ name, key, file, dataMode, intent }] }. Pass--languagesthrough from the environment probe — omitting it silently defaults every plan to English-only and drops the localization pattern. The command fails (before writing) if the plan would name a sample that doesn't exist. -
Generate — for each page from step 3 with
intent: true, dispatch the headlessgenpage-page-builderworker viaTask. Use its documented input contract verbatim — a missing field is why a page silently never becomes.tsx:You are the genpage-page-builder agent. Generate the [name from step 3] page.
- Target file: [file from step 3 — already includes .tsx; do NOT append another]
- Plan document: [absolute path to the app-builder-page-plan.md written in step 3]
- Data mode: [dataMode from step 3 —
dataverseormock] - Connectors: disabled
- RuntimeTypes: [absolute path to RuntimeTypes.ts] ← omit this line when Data mode is
mock - Working directory: [absolute working-dir path]
- Plugin root: ${PLUGIN_ROOT}
Follow the instructions in your agent file. Write [file] and return your result when done.
The plan's
## EnvironmentcarriesMode: app-builderand every page row carries a Key, so the worker emits"PAGEREF_<key>"for cross-page navigation (never a file-derived token — a downloaded page'scodeFileis a path, not its identity). Custom nav ids go indata:— neverrecordId.Connectors: disabledis a constant here: the App Spec has no connector-binding concept, so the projected plan always saysNo connector bindings. -
Validate + commit the transition (transactional) — never flip
sourceby hand, and never flip pages one at a time as workers return. Run:node "${PLUGIN_ROOT}/scripts/promote-intent-pages.js" \ --spec @<working-dir>/app-spec.json --working-dir <working-dir>It checks every generated page (file written, structurally a module,
PAGEREF_tokens canonical and in exact parity with the spec'snavigatesToedges) and only then flips all of themintent → { kind: "tsx", codeFile }in a single atomic write. On any failure it exits 3 and leavesapp-spec.jsonuntouched, printing which page failed and why — regenerate just those pages and re-run. All-or-nothing on purpose: a half-flipped spec would claim a.tsxthat was never written, and Phase 2'sdeployprofile fails fast on any remainingsource.kind === "intent". -
Proceed to Phase 2 (full idempotent build).
⚠️ The interactive author never runs inside a
Tasksubagent. Only pure, headless code-gen workers are dispatched here — all user-facing prompts originate in the main loop.
Phase 2 — Build (narrated, main loop)
Always use
scripts/build-model-app.js. Never hand-write a builder. It's idempotent (skips existing solution/tables/columns/relationships — so new, existing, and mixed envs all just work), so you don't pre-create anything or special-case existing tables.
The build plan was already presented and approved in plan mode (Phase 1 Step 6 shows the engine's
real dry-run plan), so on approval apply directly — one build approval, no second go-ahead. Add
--verify so the build self-checks after applying (see Phase 3):
node "${PLUGIN_ROOT}/scripts/build-model-app.js" \
--env <envUrl> --spec @<working-dir>/app-spec.json --apply --verify [--sample-data] [--publish]
Each step streams its status live ([n/total] ✓ created / ⊘ skipped / ✗ failed — <error>) and a
closing ✓ build complete — X created, Y skipped, Z failed summary.
Keep the build's progress visible. A full build runs for several minutes. Let its output stream — do NOT pipe it through
Select-Object -First/-Last NorSelect-Stringhead-limits, which buffer and hide progress until the run ends (and can truncate a still-running pipe). To capture the log useTee-Object -FilePath <log>(no head-limit). The build also prints a▸ live progress:line pointing at<workspace>/.maker-workspace/build-status.json— a snapshot (state,steps,lastPhase,lastLabel) overwritten every step. Read it (or tailbuild-log.jsonl) any time to report where a long build is, even if stdout is buffered.
(Reaching Phase 2 without a fresh plan-mode approval — resuming a failed build, or a quick edit
re-run — do a dry-run first (drop --apply), paste the phase-grouped plan verbatim into your
chat reply (tool output is collapsed — see the CRITICAL note above), and get a go-ahead
before applying.)
Stage selector (--stage <data|ui|app|publish>) maps to its phase range. On --apply, ONLY
--stage data is accepted (solution + data-model, no rows in run 1; run 2 is a full build). All
other stages and the legacy --from/--to/--only/--skip selectors are dry-run inspection only —
their phase ranges are not dependency-closed and are rejected on --apply.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 868
- Forks
- 177
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
app-builder-microsoft- Source
- github.com/microsoft/power-platform-skills