/generate-pcf-companion
SkillCommunicationGenerate the dispatcher PCF for a third-party `.ppmplugin` (wrap-runtime) control. Runs `pac pcf init` in the pcf/ subfolder, rewrites ControlManifest.Input.xml from ARCHITECTURE §6, and writes index.ts derived from ARCHITECTURE §4 (message contract), §8 (PCF surface), §9 (error UX) — no placeholders. The bridge dispatches the composite key `<name>/<receiver>` to `NativeModules.<nativeModule>.<method>` via the host-injected `window.PowerApps.NativeExtension.sendAsync` global (never `cordova.exec` — not in the PCF sandbox); also emits a `PowerAppsNativeExtension.d.ts` ambient declaration. Responses are peeled with `extractResponse`. Emits structured JSON debug/error logs. Validated by `npm run build`. **Local only** — does not deploy. Needs only `pac` CLI. Run after the native module exists. Uses npm (not pnpm).
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 /generate-pcf-companion skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/power-apps-mobile-extension/skills/generate-pcf-companion/SKILL.md and read by ahel’s review.
Generates the dispatcher PCF — the Canvas Studio control that calls the third-party native module through the host-injected window.PowerApps.NativeExtension.sendAsync global, routed by the composite key <name>/<receiver> read from the committed ./manifest.json (the source of truth /generate-native-extension authors at scaffold time). Lives at pcf/<Pascal>PCF/ in the same repo the native module lives in. The PCF is a Studio-side companion; it is NOT part of the .ppmplugin bundle (the bundle ships native binaries only — manifest.json + android//ios/).
This skill assumes the native module already exists in the repo. Run it after the module is in place.
PCF framework reference (public Microsoft Learn docs). Ground
pac pcf init, theControlManifest.Input.xmlschema, theinit/updateView/getOutputs/destroylifecycle, and theusage(bound/input/output) rules against the official Power Apps Component Framework docs — they are the authority when this skill's templates and the live framework disagree. (ThesendAsynctransport +extractResponseresponse-unwrap specifics are this track's own, inshared/ppmplugin-format.md §2— not in these generic PCF docs.)
- Overview: https://learn.microsoft.com/en-us/power-apps/developer/component-framework/overview
- Create a code component: https://learn.microsoft.com/en-us/power-apps/developer/component-framework/create-custom-controls-using-pcf
- Custom controls overview: https://learn.microsoft.com/en-us/power-apps/developer/component-framework/custom-controls-overview
Step 1 — Read the shared docs and the PRD
-
Read
shared/shared-instructions.md,shared/naming-conventions.md,shared/ppmplugin-format.md,shared/repo-layout.md. -
Apply the per-skill minimal prereq policy (
shared-instructions.md §1.5). This skill needs Node +pacCLI only —pac pcf initis a local file generator andnpm install/npm run buildunderpcf/only needs Node. It does NOT need pnpm, package-feed authentication, .NET SDK runtime, or activepac auth.Print the prereq status as a visible block per
shared-instructions.md §9.2before continuing:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Prereq check — /generate-pcf-companion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ Node 20+ installed (for npm install + tsc under pcf/) 🟢 ✓ pac CLI installed (for pac pcf init) 🟢 2 checks passed. Ready to proceed.If
pacis missing, STOP with the fix command (dotnet tool install -g Microsoft.PowerApps.CLI.Tool— note: installingpacrequires .NET SDK as a one-time install, but neither .NET norpac authis needed at runtime for scaffold). If Node is missing, STOP with the install instruction. Run the/generate-pcf-companioncheck fromprereq-check.md(Node +paconly — this self-contained track has no "baseline" check)..NET SDK + active
pac authare NOT checked here. If the user later picks the optional "Yes, also deploy now" path in Step 2, the deploy prereq one-liner is run at that point (just-in-time, beforepac pcf push). -
Read
./PRD.md. If missing or §8 (PCF surface) is incomplete (any<NEEDS INPUT>or missing fields in §8.1–§8.4), STOP withBLOCKED: PRD.md §8 PCF surface is incomplete — re-run /design-native-extension-feature and complete the PCF section. -
Read
./.extension-state.md. If Phase isn't at leastscaffold, STOP withBLOCKED: run /generate-native-extension first.The structural patterns this skill needs to emit (manifest shape,index.tsbridge wiring, output mapping) are fully prescribed in this SKILL.md (§4–§5) and inshared/ppmplugin-format.md§2 (Runtime dispatch contract). Do NOT fetch the reference extension repo at runtime — its lessons are already encoded here, and fetching it would risk reference-specific UI logic bleeding into an unrelated PCF.
Step 1.5 — Resolve the dispatch contract from ./manifest.json and the native module
The wrap runtime dispatch contract (shared/ppmplugin-format.md §2) is the authoritative specification of how a host call reaches the bundle. The .ppmplugin bundle is native-only (no TS handleMessageAsync layer in the bundle), but the companion PCF dispatches through the host-injected window.PowerApps.NativeExtension.sendAsync global — it must NEVER call cordova.exec directly (the raw cordova global is not exposed to the PCF sandbox; a direct call is a silent no-op on device, worst on Android). sendAsync performs the underlying cordova.exec("SendMessagePlugin", …) transport inside the host context and routes to the React Native module the binary ships:
PCF → window.PowerApps.NativeExtension.sendAsync("<name>/<receiver>", { method, args: [request] })
→ host global (host context): cordova.exec("SendMessagePlugin", "<name>/<receiver>", [JSON.stringify({method,args}), corrId])
→ proxy → NativeModules[<nativeModule>][<method>].apply(mod, <args-array>)
The composite routing key <name>/<receiver> is what the host resolves to a module; the method is one entry from that receiver's methods[]. One PCF drives both iOS and Android through this global — no platform branch.
⚠️ TWO invariants — both confirmed on device; getting either wrong = silent failure:
- Dispatch via
sendAsync, NEVERcordova.exec. The envelope is a RAW object{ method, args: [request] }— the PCF does not stringify it;sendAsyncdoes theJSON.stringifyinternally. A PCF that callscordova.execdirectly, or that pre-stringifies the payload, fails silently on the first device tap (no error on screen; nothing dispatches — worst on Android).- The inner
argsMUST be a JSON ARRAY (ppmplugin-format §2). After parsing the envelope the proxy runsArray.isArray(parsed.args) ? parsed.args : []thenfn.apply(mod, args)— spreading it as positional arguments. A bare object → dropped → the native method gets no request data. Our convention:args: [request]— one request object, and the native method takes exactly oneReadableMap/NSDictionaryfirst parameter.
On status === "ok", sendAsync resolves result.data — the native method's resolved string. The wrap host both re-stringifies it once and nests it in a { isUpdate, message } transport container, so the PCF normalizes it with an extractResponse helper (parse result.data, then — if the parsed object has no top-level status — unwrap the message container to reach the module's {status, result} object; total-fail → PARSE error). A bare single parse lands on the container and fails every call with UNEXPECTED_PAYLOAD though native succeeded — see shared/ppmplugin-format.md §2. status !== "ok" → surface result.error (fall back to BRIDGE_FAILED); missing host global → NOT_IN_WRAP.
Required reads — must succeed before Step 2
-
Resolve the composite routing key
<name>/<receiver>from the committed./manifest.json— the source of truth/generate-native-extensionwrites at scaffold time, so on the normal flow it already exists when this skill runs. Prefer it; fall back to the staged copy, then ARCHITECTURE only if no manifest exists yet (a hand-authored module). OS-neutral: read./manifest.jsonwith the Read tool and parse the JSON directly — don't shell out togrep/sed(the bash below is illustrative; it won't run on Windows):MANIFEST=$( [ -f ./manifest.json ] && echo ./manifest.json || echo ppmplugin/staging/manifest.json ) if [ -f "$MANIFEST" ]; then NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$MANIFEST" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') echo "manifest ($MANIFEST) name: $NAME — receiver/nativeModule/methods read from receivers[]" else echo "no manifest.json yet (hand-authored module) — derive <name>=kebab(className), <receiver>=<Pascal>Extension, nativeModule=<className> from ARCHITECTURE; /generate-ppmplugin-manifest will author it" fiThe dispatch key the PCF binds and the receiver the manifest registers MUST match — a PCF that dispatches
<name>/<receiver>while the manifest registers a different receiver fails on first dispatch. Because./manifest.jsonis authored before this skill runs (at native-gen), the PCF follows the manifest's receiver — bind exactly thereceivers[].nameit declares. -
Read
manifest.jsonreceivers[](canonical dispatch target):receivers[].name— the<receiver>half of the composite keyreceivers[].nativeModule— what the host resolves asNativeModules.<nativeModule>(the module'sgetName())receivers[].methods— theMethodvalues the host may dispatch; each is a real@ReactMethod/RCT_EXPORT_METHODname. The PCF'sonTriggercalls one of these.
-
Native source (verification only —
ios/RCT<Pascal>Module.m,android/src/main/java/.../<Pascal>Module.kt):- Android
getName()/ iOS+ (NSString *)moduleNameMUST equalreceivers[].nativeModule - Every
methodthe PCF dispatches MUST be a real@ReactMethod/RCT_EXPORT_METHODon the module (an unknown method =method '<m>' not foundon device) - If native drifts from the manifest, STOP with
NEEDS_CONTEXT: native module drifted from manifest.json receivers[]; reconcile and re-run
- Android
Compose the resolved contract
Transport (host global — fixed):
window.PowerApps.NativeExtension.sendAsync("<name>/<receiver>", { method, args: [request] })
↑ envelope is a RAW object; sendAsync stringifies it internally
result.status === "ok" → extractResponse(result.data) yields the module's response object (unwraps the wrap `message` container)
result.status !== "ok" → bridge/transport failure (result.error ?? BRIDGE_FAILED); parse-fail → PARSE
no window.PowerApps.NativeExtension → NOT_IN_WRAP (Studio preview / non-PAM host / CordovaV2 off)
Dispatch target (from manifest.json receivers[]):
Composite key: <name>/<receiver>
nativeModule: <NativeModules.<nativeModule>>
method: <one of methods[]>
args: [request] — a JSON ARRAY (spread positionally via fn.apply). Our convention: ONE request
object at args[0]; the @ReactMethod / RCT_EXPORT_METHOD takes one ReadableMap/NSDictionary param.
Response shape: <list — module's own {status, result, error, message}> (message = human-readable failure reason)
Module error codes: <list — USER_CANCELLED, INVALID_INPUT, ...> (canonical set + meanings: shared/error-codes.md)
Verification (native source):
All checks: <pass | fail with mismatch>
Drift detection
| Disagreement | Action |
|---|---|
manifest.json receivers[] ↔ native source disagree on nativeModule / method names | STOP with NEEDS_CONTEXT. List the mismatches. Reconcile before generating PCF. |
PCF composite key <name>/<receiver> ↔ manifest's registered receiver disagree | The PCF and manifest must agree on the key. ./manifest.json is authored first (at native-gen), so the PCF follows the manifest — bind the receivers[].name it declares. (Only if the user deliberately renames the receiver in the PCF, update ./manifest.json to match and re-run.) |
Host sendAsync payload/response wire format ↔ what this skill emits | The exact envelope is owned by the host global + wrap proxy; confirm against shared/ppmplugin-format.md §2. The PCF guarantees only the composite key + method the bridge ultimately targets. |
Use the resolved contract — not the PRD's §5.1/§5.2 — as the source for the PCF's dispatch args and response parsing in Step 5. The PRD describes intent; ./manifest.json + native is the actual dispatch contract. When they agree, all three are consistent; when they don't, the native source wins (since that's what the running app sees).
Step 2 — Confirm the plan with the user
Print a summary derived from ARCHITECTURE §6 and the derived names, then gate on approval.
PCF scaffold plan
─────────────────
Folder: pcf/<Pascal>PCF/
Namespace: PowerApps (constant for all native-extension PCFs)
Control name: <Pascal>PCF
Dispatches: composite key '<name>/<receiver>' → NativeModules.<nativeModule>.<method>
via window.PowerApps.NativeExtension.sendAsync (host global)
Bound input (ARCHITECTURE §6.1 (bound input)):
<Name> : <Type> <— bound, required>
Configurable inputs (ARCHITECTURE §6.1 (configurable inputs)):
<Name> : <Type> = <default> <— purpose>
...
Output properties (ARCHITECTURE §6.1 (output properties)):
<Name> : <Type> <— purpose>
...
Trigger (ARCHITECTURE §6.2): <one line>
Use AskUserQuestion:
Proceed with this PCF scaffold?
- Yes — run
pac pcf init, write/rewrite files, runnpm install+npm run buildsmoke check. All local — no environment deploy.- Edit the PRD first — exit; user runs
/design-native-extension-featureto fix §8.- Cancel
Deployment to a Power Platform environment is a separate, on-demand step via /publish-pcf-companion. This skill is purely local — it doesn't touch pac auth, doesn't call pac pcf push, doesn't need .NET SDK.
Step 3 — Run pac pcf init
Inside the repo root:
mkdir -p pcf
cd pcf
pac pcf init --namespace PowerApps --name <Pascal>PCF --template field --framework none
Notes on the flags:
--namespace PowerApps— constant. All native-extension PCFs share this namespace so they group together in Canvas Studio's Insert panel.--template field— single-bound-value control. Matches the "trigger a native operation on a maker-set input" pattern. Don't usedatasetfor v0.--framework none— vanilla DOM. No React. Keeps the bundle tiny and avoids version friction with the host's managed build's React.
pac pcf init creates pcf/<Pascal>PCF/ with this structure:
<Pascal>PCF.pcfproj(MSBuild project)package.json(uses npm — PCF tooling convention)pcfconfig.jsontsconfig.jsoneslint.config.mjs<Pascal>PCF/(nested) —ControlManifest.Input.xml+index.ts+PowerAppsNativeExtension.d.ts(ambient host-global decl, emitted in Step 5.5) + (later)generated/ManifestTypes.d.ts
If pac pcf init fails:
- "pac not found" → re-run the prereq check. The
pwshprefix may be needed on Windows. - "folder already exists" → ask whether to delete it and regenerate, or merge (only safe if no manual edits were made).
- Auth-related → run
pac auth listand surface which profile is active; suggestpac auth createif none.
After pac pcf init succeeds, also write pcf/README.md (one level up from the control folder). Sections:
- Overview — one paragraph from PRD §1 explaining what this PCF does.
- Not in the npm tarball — explicit note that the PCF folder is excluded from
package.json'sfilesarray; it ships to Power Platform viapac pcf push, not via npm. - Properties — three short tables from ARCHITECTURE §6 (bound, configurable, output).
- Build & iterate —
npm install,npm run build,pac pcf push --publisher-prefix <2–8 char prefix>(see/publish-pcf-companionfor prefix selection). - Trigger behavior — one line from ARCHITECTURE §6.2.
Keep it ~50 lines. Tailor every section to the PRD; don't invent boilerplate.
Step 4 — Rewrite ControlManifest.Input.xml
pac pcf init produces a single-property manifest. Rewrite it to match ARCHITECTURE §6 exactly.
Use human-readable text for display-name-key and description-key. These attributes are what the maker sees in Power Apps Studio's properties panel — they're not just internal keys. Without .resx resource files (which this scaffold doesn't ship), Studio displays the attribute value verbatim. Write friendly labels and sentences, not programmer-style keys.
⚠️ HARD RULE — no apostrophes (and no raw
<>&) in these attributes.pac pcf pushvalidates the manifest against an XSD wheredisplay-name-key/description-keyarenoAposStringType— a literal ASCII apostrophe (') fails the push withnoAposStringTypevalidation. It also breaks on raw XML metacharacters. So when deriving these strings:
- Rephrase to avoid possessives/contractions rather than emitting an apostrophe — e.g. "the phone's flashlight" → "the device flashlight" / "the phone flashlight"; "doesn't" → "does not"; "user's" → "the user". This reads cleanest.
- If a string genuinely must keep the punctuation, use the typographic right single quote
’(U+2019), which is NOT the ASCII apostrophe and passes the XSD — but prefer rephrasing.- Escape or avoid
&(&),<,>. Keep these attributes plain ASCII sentences.- This applies to every
display-name-key/description-keyin the manifest (control + each property). Scan the final manifest for'before writing it.
Derivation rules:
| Attribute | Value |
|---|---|
<control display-name-key="..."> | PRD §2 "Human-readable name" if present; else convert <Pascal>PCF to title case (e.g. BarcodeScannerPCF → Barcode scanner) |
<control description-key="..."> | PRD §1 Summary, trimmed to ~120 chars (single sentence) |
<property display-name-key="..."> | Convert the property name to title case with spaces (e.g. PenColor → Pen color, SignatureBase64 → Signature base64) |
<property description-key="..."> | The "Purpose" column from ARCHITECTURE §6.1 (bound input) / §8.2 / §8.3 for that property |
The manifest structure (substitute the human-readable strings, NOT placeholder keys):
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
<control namespace="PowerApps"
constructor="<Pascal>PCF"
version="0.0.1"
display-name-key="<human-readable name from PRD §2>"
description-key="<short summary from PRD §1>"
control-type="standard">
<!-- §8.1 Bound input — OPTIONAL, at most one, usage=bound. OMIT this block
entirely unless there is a single primary column the control both reads AND
writes back (text editor, scrubber, chart). Most native-extension PCFs are
action/config controls and have NO bound property — see the usage table below. -->
<property name="<BoundName>"
display-name-key="<title-cased BoundName>"
description-key="<Purpose from ARCHITECTURE §6.1 (bound input)>"
of-type="<Type>"
usage="bound"
required="true" />
<!-- §8.2 Configurable inputs — usage=input, required="false". Values the maker
TYPES or PICKS in the property panel (read-only to the control). -->
<property name="<ConfigName>"
display-name-key="<title-cased ConfigName>"
description-key="<Purpose from ARCHITECTURE §6.1 (configurable inputs)>"
of-type="<Type>"
usage="input"
required="false"
default-value="<default>" />
<!-- ... one <property> per configurable input ... -->
<!-- §8.3 Output properties — usage=output. Values the control PRODUCES that the
maker READS in Power Fx (Self.PropertyName) — status, result, error, computed
text. These are NOT bound and NOT input. Every runtime value the maker consumes
is an output, NOT a bound prop. Declare each in IOutputs + return from getOutputs(). -->
<property name="<OutputName>"
display-name-key="<title-cased OutputName>"
description-key="<Purpose from ARCHITECTURE §6.1 (output properties)>"
of-type="<Type>"
usage="output" />
<!-- ... one <property> per output ... -->
<!-- On-device diagnostic — the ONE legitimate usage="bound" in a wrap PCF.
On a release wrap build the WebView console is unreachable from logcat /
chrome://inspect, so the PCF surfaces the RAW bridge response (the wire string
exactly as it arrived, before extractResponse) here. The maker drops it on a Power Fx
label (Self.<name>Json) and reads what actually came back with no connected
debugger. See shared/ppmplugin-format.md §2 "Wrap-bridge response quirks". -->
<property name="<name>Json"
display-name-key="<title-cased name> raw response"
description-key="Raw bridge response for on-device debugging — drop on a label as Self.<name>Json."
of-type="SingleLine.Text"
usage="bound" />
<resources>
<code path="index.ts" order="1" />
</resources>
</control>
</manifest>
Illustrative example (substitute the actual <Pascal> and property names from PRD §2 + §8):
<control namespace="PowerApps"
constructor="<Pascal>PCF"
version="0.0.1"
display-name-key="<Human-readable name from PRD §2>"
description-key="<One-line description from PRD §1.>"
control-type="standard">
<property name="<InputPropertyName from ARCHITECTURE §6.1 (configurable inputs)>"
display-name-key="<Human-readable label>"
description-key="<One-line description>"
of-type="SingleLine.Text"
usage="input"
required="false"
default-value="<default from ARCHITECTURE §6.1 (configurable inputs)>" />
<property name="<OutputPropertyName from ARCHITECTURE §6.1 (output properties)>"
display-name-key="<Human-readable label>"
description-key="<One-line description>"
of-type="SingleLine.Text"
usage="output" />
...
</control>
PCF property types you'll commonly see: SingleLine.Text, SingleLine.URL, SingleLine.Email, Whole.None, Decimal, TwoOptions, DateAndTime.DateOnly, DateAndTime.DateAndTime. Map the PRD's TypeScript types accordingly (e.g. string → SingleLine.Text unless context says URL).
Standard diagnostic outputs — ALWAYS emit these three
In addition to the operation's result outputs (and the <name>Json raw-response bound output above), every dispatcher PCF MUST declare three diagnostic outputs (all of-type="SingleLine.Text", usage="output"). For a third-party control this matters even more than first-party: the native binary runs inside the customer's wrap shell with no logcat / Xcode console / native debugger reachable, so the only way a failure is visible at all is if the code + message ride back through the bridge into a formula-readable output:
<property name="Status" display-name-key="Status" description-key="ok | error | cancelled" of-type="SingleLine.Text" usage="output" />
<property name="ErrorCode" display-name-key="Error Code" description-key="Machine-readable error code; empty on success" of-type="SingleLine.Text" usage="output" />
<property name="ErrorMessage" display-name-key="Error Message" description-key="Human-readable failure reason; empty on success" of-type="SingleLine.Text" usage="output" />
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 859
- Forks
- 176
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
generate-pcf-companion- Source
- github.com/microsoft/power-platform-skills