/generate-pcf-companion

SkillCommunication

Generate 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.

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, the ControlManifest.Input.xml schema, the init/updateView/getOutputs/destroy lifecycle, and the usage (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. (The sendAsync transport + extractResponse response-unwrap specifics are this track's own, in shared/ppmplugin-format.md §2 — not in these generic PCF docs.)


Step 1 — Read the shared docs and the PRD

  1. Read shared/shared-instructions.md, shared/naming-conventions.md, shared/ppmplugin-format.md, shared/repo-layout.md.

  2. Apply the per-skill minimal prereq policy (shared-instructions.md §1.5). This skill needs Node + pac CLI only — pac pcf init is a local file generator and npm install/npm run build under pcf/ only needs Node. It does NOT need pnpm, package-feed authentication, .NET SDK runtime, or active pac auth.

    Print the prereq status as a visible block per shared-instructions.md §9.2 before 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 pac is missing, STOP with the fix command (dotnet tool install -g Microsoft.PowerApps.CLI.Tool — note: installing pac requires .NET SDK as a one-time install, but neither .NET nor pac auth is needed at runtime for scaffold). If Node is missing, STOP with the install instruction. Run the /generate-pcf-companion check from prereq-check.md (Node + pac only — this self-contained track has no "baseline" check).

    .NET SDK + active pac auth are 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, before pac pcf push).

  3. Read ./PRD.md. If missing or §8 (PCF surface) is incomplete (any <NEEDS INPUT> or missing fields in §8.1–§8.4), STOP with BLOCKED: PRD.md §8 PCF surface is incomplete — re-run /design-native-extension-feature and complete the PCF section.

  4. Read ./.extension-state.md. If Phase isn't at least scaffold, STOP with BLOCKED: run /generate-native-extension first. The structural patterns this skill needs to emit (manifest shape, index.ts bridge wiring, output mapping) are fully prescribed in this SKILL.md (§4–§5) and in shared/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:

  1. Dispatch via sendAsync, NEVER cordova.exec. The envelope is a RAW object { method, args: [request] } — the PCF does not stringify it; sendAsync does the JSON.stringify internally. A PCF that calls cordova.exec directly, or that pre-stringifies the payload, fails silently on the first device tap (no error on screen; nothing dispatches — worst on Android).
  2. The inner args MUST be a JSON ARRAY (ppmplugin-format §2). After parsing the envelope the proxy runs Array.isArray(parsed.args) ? parsed.args : [] then fn.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 one ReadableMap/NSDictionary first 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

  1. Resolve the composite routing key <name>/<receiver> from the committed ./manifest.json — the source of truth /generate-native-extension writes 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.json with the Read tool and parse the JSON directly — don't shell out to grep/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"
    fi
    

    The 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.json is authored before this skill runs (at native-gen), the PCF follows the manifest's receiver — bind exactly the receivers[].name it declares.

  2. Read manifest.json receivers[] (canonical dispatch target):

    • receivers[].name — the <receiver> half of the composite key
    • receivers[].nativeModule — what the host resolves as NativeModules.<nativeModule> (the module's getName())
    • receivers[].methods — the Method values the host may dispatch; each is a real @ReactMethod / RCT_EXPORT_METHOD name. The PCF's onTrigger calls one of these.
  3. Native source (verification only — ios/RCT<Pascal>Module.m, android/src/main/java/.../<Pascal>Module.kt):

    • Android getName() / iOS + (NSString *)moduleName MUST equal receivers[].nativeModule
    • Every method the PCF dispatches MUST be a real @ReactMethod / RCT_EXPORT_METHOD on the module (an unknown method = method '<m>' not found on device)
    • If native drifts from the manifest, STOP with NEEDS_CONTEXT: native module drifted from manifest.json receivers[]; reconcile and re-run

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

DisagreementAction
manifest.json receivers[] ↔ native source disagree on nativeModule / method namesSTOP with NEEDS_CONTEXT. List the mismatches. Reconcile before generating PCF.
PCF composite key <name>/<receiver> ↔ manifest's registered receiver disagreeThe 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 emitsThe 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, run npm install + npm run build smoke check. All local — no environment deploy.
  • Edit the PRD first — exit; user runs /design-native-extension-feature to 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 use dataset for 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.json
  • tsconfig.json
  • eslint.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 pwsh prefix 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 list and surface which profile is active; suggest pac auth create if none.

After pac pcf init succeeds, also write pcf/README.md (one level up from the control folder). Sections:

  1. Overview — one paragraph from PRD §1 explaining what this PCF does.
  2. Not in the npm tarball — explicit note that the PCF folder is excluded from package.json's files array; it ships to Power Platform via pac pcf push, not via npm.
  3. Properties — three short tables from ARCHITECTURE §6 (bound, configurable, output).
  4. Build & iteratenpm install, npm run build, pac pcf push --publisher-prefix <2–8 char prefix> (see /publish-pcf-companion for prefix selection).
  5. 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 push validates the manifest against an XSD where display-name-key / description-key are noAposStringTypea literal ASCII apostrophe (') fails the push with noAposStringType validation. 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 & (&amp;), <, >. Keep these attributes plain ASCII sentences.
  • This applies to every display-name-key / description-key in the manifest (control + each property). Scan the final manifest for ' before writing it.

Derivation rules:

AttributeValue
<control display-name-key="...">PRD §2 "Human-readable name" if present; else convert <Pascal>PCF to title case (e.g. BarcodeScannerPCFBarcode 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. PenColorPen color, SignatureBase64Signature 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. stringSingleLine.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