46 added, 0 removed. Audit A to A.
# Power Platform Skills - Development Guidelines
This file provides guidance to AI Agents when working with code in this repository.
## What This Repo Is
A **plugin marketplace** for Power Platform development by Microsoft. The Open Plugins marketplace manifest (`marketplace.json`) references individual plugins in `plugins/`. Each plugin has its own `AGENTS.md` with plugin-specific guidance.
+ ## This Repo Is PUBLIC — keep internal detail out of it
+
+ Everything here — code, docs, commit messages, PR descriptions, and branch names — is world-readable.
+ Write for a reader outside Microsoft who cannot see any internal system.
+
+ **Do not commit:**
+
+ - **Internal hosts, repos or paths** — `*.ghe.com` links, internal repo names, or a path into an
+ internal repo's doc tree. Naming one discloses its existence and its structure.
+ - **References to internal documents**, including indexes into them — "spec rank 14",
+ "Group N P1", "(resolves C2, I1)", "see the R1 review". A reader who cannot open the document
+ gets nothing from the pointer, and it advertises the document.
+ - **Real environment, tenant or org identifiers** — Dataverse environment names/URLs, tenant GUIDs,
+ subscription ids. These name real infrastructure. In examples use an obvious placeholder
+ (`https://contoso.crm.dynamics.com`, `<envUrl>`); to record that something was live-verified, keep
+ the **claim** and drop the environment ("live-verified", not "live-verified on <envname>").
+ - **Internal review process** — who or which model reviewed something, how many rounds, internal
+ finding ids. "Adversarially reviewed" is a useful signal; the rest is internal.
+ - **Exploratory design docs for UNBUILT work** — roadmaps, prioritisation, and candid notes on
+ limitations. A design doc for something that **shipped** is fine and often valuable (see
+ `plugins/model-apps/docs/`); a proposal for something that has not is internal.
+ - **Pointers to files that are not in the repo** — a citation nobody can resolve is noise at best.
+
+ **Fine to commit:** ADO / `AB#` work-item ids (opaque, and `AB#` is the standard Azure Boards ↔
+ GitHub link syntax), and the location of a first-party source repo a maintainer needs in order to
+ rebuild a vendored bundle. Both carry real "why" context and disclose no content.
+
+ **When you must record internal context**, put it in the PR conversation or an internal doc — not in
+ a committed file. When editing an existing doc, keep this rule in mind for the lines around your
+ change, not just the ones you add.
+
+ **CI enforcement (partial).** `node scripts/validate-no-real-environments.js` (wired into the
+ `validate-repository-metadata` workflow) fails the build when a real Dataverse host, tenant, or
+ previously-removed identifier appears under `plugins/model-apps/**` or `evals/model-apps/**`. It
+ matches on *shape* — `org<8 hex>` is what Dataverse auto-generates, so it is rejected even though it
+ starts with the otherwise-allowed word `org` — rather than only re-catching known strings. Run it
+ locally after touching eval fixtures or any file that quotes an environment URL.
+
+ The scan is **scoped to model-apps only**, and this is a real gap rather than an oversight: other
+ plugins still carry pre-existing references of this class (for example real `org<8 hex>` orgs cited
+ in power-pages provenance comments), so widening the scan today would fail unrelated PRs. Scrub a
+ plugin first, then add it to `SCAN_PATHS`. The guard also cannot see the *local part* of a UPN, so
+ `firstname.lastname@contoso.onmicrosoft.com` passes — use a role word (`maker@`, `tester@`).
+ Captured `pac auth list` transcripts are the most common source of all three; when scrubbing one,
+ prefer an **equal-length** placeholder so the fixed-width table stays aligned.
+
## Repository Structure
```
power-platform-skills/
├── marketplace.json # Open Plugins marketplace manifest (lists all available plugins)
├── .claude-plugin/ # Legacy manifest mirrors for existing subscriptions
│ └── marketplace.json
├── plugins/ # Directory containing individual plugins
│ └── <plugin-name>/ # Individual plugin (e.g., power-pages)
│ ├── .plugin/
│ │ └── plugin.json # Plugin manifest
│ ├── .claude-plugin/
│ │ └── plugin.json # Legacy manifest mirror
│ ├── AGENTS.md # Plugin-specific development guidelines
│ ├── agents/ # Agent persona files
│ ├── commands/ # Command entry points
│ ├── shared/ # Shared resources and documentation
│ └── skills/ # Skill workflows (SKILL.md in subdirectories)
├── shared/ # Cross-plugin shared resources
│ └── skills/ # Shared skill definitions
│ └── <skill-name>/ # SKILL.template.md + workflow .md files
├── AGENTS.md # Generic development guidelines (this file)
└── README.md # Repository overview
```
## Local Development
Test a plugin locally by launching your AI agent with the plugin path:
```bash
claude --plugin-dir /path/to/plugins/<plugin-name>
```
No root-level build, lint, or test commands exist. Build/test tooling lives inside each plugin.
## CI
**Only two workflows run on every PR** — `validate-keyword-case` and `validate-repository-metadata`.
Both are repo-wide and enforce metadata/marketplace rules, not behavior.
**Every test workflow is path-filtered to a single plugin** (`power-pages` → `plugins/power-pages/**`;
`model-apps` → `plugins/model-apps/**` + `evals/model-apps/**`). This is deliberate — a PR should not
spend CI on a plugin it never touched — but it has a corollary: *a green PR does not mean the repo is
green*, only that the paths you touched are.
**A test suite with no workflow silently never runs.** When you add tests to a plugin, add or extend
that plugin's own path-filtered workflow in the same PR; do not widen another plugin's filter to
cover yours.
A plugin that has adopted telemetry must also set its opt-out env var on any job that could execute a
telemetry-emitting hook or script (see `## Shared Telemetry`) — e.g.
`POWER_PLATFORM_SKILLS_TELEMETRY_MODEL_APPS_OPTOUT: "1"`. It suppresses transmission only, so it
cannot change what a test asserts.
## Plugin Conventions
Each plugin follows this structure:
- `.plugin/plugin.json` — Open Plugins metadata (name, version, keywords)
- `.claude-plugin/plugin.json` — legacy mirror of `.plugin/plugin.json` kept for existing subscriptions
- `.mcp.json` — MCP server configuration (optional)
- `agents/` — Agent definitions (`.md` files with YAML frontmatter)
- `skills/` — Skill definitions, each in its own subdirectory with a `SKILL.md`
- `scripts/` — Shared utility scripts referenced by skills and agents
- `references/` — Shared reference documents used by multiple skills
Skills are defined in `SKILL.md` files with YAML frontmatter (name, description, allowed-tools, model, hooks). The `allowed-tools` field must use a **comma-separated list** (e.g., `allowed-tools: Read, Write, Edit, Bash, Glob, Grep`) — not JSON array syntax (`["Read", "Write"]`) or YAML list syntax. Each skill may include a validation script in a `scripts/` subdirectory (the first `validate*.js`, discovered automatically). It is run by the plugin's **`PostToolUse` hook on the `Skill` tool** — `hooks/run-skill-posttool-validation.js` looks the script up and executes it after the Skill tool returns, propagating its exit code. Note this fires when the skill is **invoked**, not when its work finishes, so a validator must no-op (approve) when the artifacts it checks are not present yet.
## Cross-Plugin Shared Skills
Skills that apply to all plugins live in `shared/skills/<skill-name>/`. The workflow logic is written once in a shared `.md` file, and each plugin has a thin `skills/<skill-name>/SKILL.md` that contains only the YAML frontmatter and a reference to the workflow path bundled inside that plugin at install time.
**Pattern:**
- `shared/skills/<skill-name>/<workflow>.md` — Full workflow (phases, instructions, field definitions)
- `shared/skills/<skill-name>/SKILL.template.md` — Template SKILL.md (frontmatter + reference to workflow); supports `{{PLUGIN_NAME}}` placeholder
- `plugins/<plugin>/skills/<skill-name>/SKILL.md` — Per-plugin wrapper generated from the template above
- `plugins/<plugin>/skills/<skill-name>/<workflow>.md` — Copied workflow file bundled with the plugin so it works after installing only its own plugin directory
This keeps the skill discoverable in each plugin while preserving install-time portability. Marketplace installs copy only the plugin directory, so per-plugin wrappers must not reference repo-root `shared/` paths at runtime. Instead, point the wrapper at `${PLUGIN_ROOT}/skills/<skill-name>/<workflow>.md` and keep a physical copy of the shared workflow at that per-plugin path. Do not use Git symlinks for shared content; Windows and plugin-host installs can materialize them as plain link files. When updating a shared skill, edit the workflow file and/or `SKILL.template.md` in `shared/`, then refresh the per-plugin wrappers (frontmatter + bundled workflow reference, with `{{PLUGIN_NAME}}` substituted) and copy the workflow content into each adopting plugin. Commit the shared source and per-plugin copies together.
## Shared Telemetry
1DS telemetry code for all plugins lives at `shared/telemetry/`. Each adopting plugin keeps a physical copy of the library in its own tree at `plugins/<plugin>/scripts/lib/telemetry/lib`, alongside that plugin's real `ikey.json`. Do not use Git symlinks for this copy; plugin hosts may not dereference them reliably.
Edit `shared/telemetry/` first, then refresh every adopting plugin's copied `scripts/lib/telemetry/lib` directory in the same change so the canonical source and bundled plugin content stay in sync.
`eventInfo` is a dynamic escape hatch whose nested keys are not enforced by `FIELD_TYPES`. Follow the approved schema in `shared/telemetry/README.md`; do not add nested fields or arbitrary payloads without privacy review and coordinated disclosure, schema, and test updates.
**Never reuse another plugin's instrumentation key or event stream.** When adopting telemetry in a new plugin, copy only the routing-agnostic library (`shared/telemetry/lib` → `plugins/<plugin>/scripts/lib/telemetry/lib`) — do **not** copy an existing adopter's real `ikey.json` (or its `resolver.js`). Each plugin's `ikey.json` carries that plugin's own instrumentation key(s), collector routing, and `event_stream_name`; start from the placeholder `shared/telemetry/ikey.json` (every region key is `PLACEHOLDER_REPLACE_BEFORE_SHIPPING` and it ships `disabled: true`) and provision a fresh, plugin-specific key before shipping. Copying a key already committed to another plugin (e.g. lifting `power-pages`'s `ikey.json` wholesale) mis-attributes the new plugin's events to the other plugin's Kusto stream and pollutes it — the copy step must bring over library code only, never another plugin's provisioned `ikey.json`/`resolver.js`.
This invariant is CI-enforced: `node scripts/validate-telemetry-ikeys.js` (wired into the `validate-repository-metadata` workflow) scans every `plugins/*/**/ikey.json`, ignores placeholder/empty values, and fails if the same instrumentation key or `event_stream_name` appears under two different plugins. A single plugin reusing one key across regions is allowed; only cross-plugin reuse fails. Run it locally after touching any plugin's `ikey.json`.
Per-plugin iKey/collector routing is pluggable via a `resolver.js` placed next to the plugin's `ikey.json` (implementing the `resolve`/`isProvisioned` contract); the shared library ships only that contract plus a static-key fallback, not any routing logic. A per-plugin opt-out env var `POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT` (derived as the uppercased plugin name with non-alphanumerics collapsed to `_`, suffixed `_OPTOUT`) disables transmission for automation when set to `1`/`true` (dotnet `*_TELEMETRY_OPTOUT` convention); it has the **highest precedence**, overriding both the persisted `config.json` choice and `/<plugin>:telemetry on`.
### CI must opt out of telemetry transmission
An adopting plugin's committed `ikey.json` ships **enabled** (`disabled: false`) with a real production instrumentation key, so any process that runs a telemetry-emitting hook or script **without isolating emission** will POST a real (but fake-in-content) event to the production collector. CI runs are not real usage, and such events pollute the production telemetry stream.
**Therefore: every GitHub Actions job that runs the test suite — or any step that could execute a telemetry-emitting hook/script for an adopting plugin — MUST set the plugin's opt-out env var at the job (or workflow) level.** For `power-pages`:
```yaml
jobs:
<job-name>:
runs-on: <runner>
env:
POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT: "1"
steps: ...
```
This opt-out suppresses **transmission only** (the local diagnostic mirror is still written), so it is safe and has no effect on what the job actually tests. Tests that need to assert that emission *happens* clear the var in their own spawned-process env and route the event to a local `POWER_PLATFORM_SKILLS_FAKE_HTTPS` probe instead of the real collector — so the job-level opt-out never breaks them. Existing reference: `.github/workflows/power-pages-script-tests.yml`. When you add a new such workflow (or a new emitting step to an existing one), add this env var in the same change; treat a CI job that runs the tests without it as a production-telemetry leak.
Current adopters: `power-pages`. Others adopt on demand.
## Legacy Marketplace Compatibility
Keep the root `.claude-plugin/marketplace.json` and each plugin's
`.claude-plugin/plugin.json` as JSON mirrors of their Open Plugins counterparts.
The shared root marketplace must stay dual-compatible while keeping per-plugin
entries minimal: each plugin entry should include only the required `name` and
repository-root-relative `source` fields. Keep marketplace-level `owner` and
`metadata` because they describe the collection, but store per-plugin display/update
metadata (description, version, license, keywords, etc.) in each `.plugin/plugin.json`
instead of duplicating or overriding it in the marketplace index. Existing marketplace
subscriptions may still resolve the legacy paths during auto-update, so removing or
drifting these files can force users to reinstall. Because mirrors are committed
files (not symlinks), update both source and legacy copies together, then run
`node scripts/validate-legacy-compatibility.js` after metadata changes.
## Code Conventions
**DRY (Don't Repeat Yourself):** Never duplicate logic across files. Each plugin has shared utilities (e.g., `scripts/lib/`) and shared reference docs (e.g., `references/`). Always check for and reuse existing helpers before writing new code. When adding shared logic, put it in the plugin's shared modules — not in individual skill directories.
### Code comments
Most code in this repo is Node.js scripts and hooks that shell out to `pac`/`az`, call the Dataverse and Power Platform APIs, and parse loosely structured CLI output. The reasoning behind a line is rarely obvious from the line alone, so comments matter.
* Err on the side of over-commenting code when the reasoning is not obvious. Comments should explain **WHY** code is written a particular way; the **WHY** is the most important part.
* Do comment non-obvious implementation details: concurrency hazards, lifecycle constraints, compatibility requirements, platform quirks, upstream PAC CLI / Dataverse workarounds, and intentional deviations from the obvious helper or API.
* When parsing strings, logs, CLI output, OData payloads, or other loosely structured data, include a comment with an example of the raw format being parsed. Show edge cases, escaping rules, delimiters, optional fields, or malformed-but-observed inputs when they affect the parser.
* When code follows an external standard, protocol, or Power Platform convention (Dataverse status codes, OData error shapes, telemetry field contracts), include valid links to the relevant Microsoft Learn or specification source so future readers can verify the rule and understand why the code follows it.
* When code touches telemetry, auth tokens, or anything privacy/security-sensitive, explain the scope, the opt-in/fail-closed behavior, and **why** — not just what it does.
* Do not add comments that simply narrate clear code, such as "set the interval" immediately before assigning an interval.
* Keep workaround comments close to the workaround. Include an issue link when the workaround is tied to an upstream bug, and describe the condition for removing it when that is known.
Good comments explain the constraint or tradeoff:
```javascript
// `pac auth who` cold-starts the .NET runtime (~4s on Windows), so cache the parsed
// result per process — repeated hook invocations must only fork the CLI once.
let cachedAuth;
```
```javascript
// Refresh the bearer token roughly every 60s instead of on every poll. A long solution
// export outlives the token's lifetime, but refreshing each 5s cycle would hammer the
// az CLI for no benefit.
const tokenRefreshEvery = Math.max(1, Math.floor(60000 / intervalMs));
```
```javascript
// Telemetry must never break the hook it runs inside, so this is fail-closed: a missing
// executable, a timeout, or an unparseable banner all resolve to null rather than throw.
return null;
```
```javascript
// Allowlist-only scrubbing: the event spec already restricts payload fields to values
// that cannot carry PII, so this is a documented seam for a future regex pass — not a
// no-op left unfinished by mistake.
function scrub(value) {
return value;
}
```
Code that follows an external standard or convention should link the source:
```javascript
// Dataverse asyncoperations terminal states: statecode 3 (Completed) with statuscode 30
// (Succeeded) means done; 31 (Failed) and 32 (Canceled) are the failure terminals.
// See: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/asyncoperation
if (statecode === 3 && statuscode === 30) {
return { status: 'Succeeded' };
}
```
Keep workaround comments next to the workaround and link the tracking issue:
```javascript
// Workaround: `pac solution export` can exit 0 while the Dataverse async job is still
// running, so ignore the exit code and poll asyncoperations to a terminal state instead.
// Remove once the CLI blocks on the job result.
// Tracking: https://github.com/microsoft/power-platform-skills/issues/1234 (use the real issue)
const status = await pollAsyncOperation(asyncJobId, envUrl, token);
```
Parsing comments should show the raw shape and important edge cases:
```javascript
// Parse the `pac auth who` banner, a label/value block, e.g.:
// Authority: https://login.microsoftonline.com/<tenant>
// Tenant ID: 00000000-0000-0000-0000-000000000000
// User: user@contoso.com
// Values can themselves contain ':' (URLs), so match only up to the first colon after
// the label, then trim. The JSON profile files are intentionally NOT parsed — that
// format is internal and varies across PAC CLI versions.
// `label` is a fixed, code-controlled string (e.g. 'Tenant ID'), so it is safe to
// interpolate into the pattern. If a label ever comes from untrusted input, escape it
// first to avoid regex injection.
const re = new RegExp('^\\s*' + label + '\\s*:\\s*(\\S.*?)\\s*$', 'im');
```
```javascript
// Dataverse OData errors arrive as:
// { "error": { "code": "0x80040217", "message": "..." } }
// but some PAC surfaces capitalize the envelope as "Error", so check both before
// falling back to plain-text pattern matching.
const odataError = parsed.error || parsed.Error;
```
Avoid comments that restate the code:
```javascript
// Set the interval to five seconds.
const intervalMs = 5000;
// Loop over the findings.
for (const finding of findings) {
report(finding);
}
```
## Maintaining This File
When you add new plugins or change the repository-level structure, update this file. For plugin-specific changes, update the plugin's own `AGENTS.md` (e.g., `plugins/power-pages/AGENTS.md`).
## External Documentation
- <a href="https://learn.microsoft.com/en-us/power-pages/configure/create-code-sites">Power Pages Code Sites</a>
- <a href="https://learn.microsoft.com/en-us/power-platform/developer/cli/reference/pages">PAC CLI Reference</a>
- <a href="https://learn.microsoft.com/en-us/rest/api/power-platform/powerpages/websites/create-website">Create Website API</a>