/generate-native-extension

SkillDev tools

Read the approved PRD.md and generate the native sources for a third-party PAM control (the compiled `.ppmplugin` track) — iOS Obj-C `<Pascal>Module` plus optional system-frameworks podspec, Android Kotlin `<Pascal>Module` with build.gradle, AndroidManifest and ReactPackage, a dev-only private package.json (react + react-native devDeps for the builds), and the committed `./manifest.json` dispatch contract the PCF and build stage both read. No TypeScript INativeExtension layer — the contract is the manifest plus the native modules' dispatch surface. Emits the layout in shared/repo-layout.md and generates substantially complete native code (compiled later by /build-android-binary and /build-ios-binary, not here). Local only — writes files, runs no git and touches no remote or feed. PCF is generated by /generate-pcf-companion; the bundle is built by /generate-ppmplugin.

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-native-extension 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-native-extension/SKILL.md and read by ahel’s review.

Reads PRD.md in the working directory and writes the native sources for a third-party PAM control following the layout in shared/repo-layout.md. This is the native-only (compiled .ppmplugin) track — there is NO TypeScript INativeExtension / handleMessageAsync layer; the wrap host dispatches straight to NativeModules.<Pascal>Module.<method> per the manifest's receivers contract (see shared/ppmplugin-format.md §2). The output is substantially complete native code so the engineer starts at customizing OS-specific code, not writing boilerplate.

This skill writes the native module half of the repo (ios/, android/, optional podspec, dev-only package.json) and the committed ./manifest.json — the dispatch-contract source of truth. The manifest is authored here, alongside the native code it describes, because every field in it is derived from the names this scaffold emits (getName(), the @ReactMethod list, the package class); authoring it now means the Companion PCF (/generate-pcf-companion) reads a real contract instead of re-deriving one, so the composite key <name>/<receiver> can't drift between the PCF and the module. The build stage /generate-ppmplugin-manifest (inside /generate-ppmplugin) then validates + reconciles + stages this manifest rather than authoring it from scratch. The Companion PCF is generated separately by /generate-pcf-companion because it requires pac CLI and a different toolchain.


Step 1 — Read the shared docs and the PRD

Before any write:

  1. Read shared/shared-instructions.md.

  2. Apply the per-skill minimal prereq policy (shared-instructions.md §1.5). This track is self-contained (shared-instructions §0a) and uses only the working tree and public package registries. This skill needs no toolchain to write the files — optionally Node + pnpm to seed the dev-only package.json's devDeps from the public npm registry (used later by /build-android-binary / /build-ios-binary, not here). Step 4's smoke check is a structural self-check — it does NOT compile anything. Run the /generate-native-extension check from prereq-check.md (git required; Node/pnpm optional — there is no "baseline" check in this self-contained track).

    Print the prereq status as a visible block per shared-instructions.md §9.2 before continuing:

    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
     Prereq check — /generate-native-extension
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    
     🟢 ✓ git installed
     🟢 ✓ Node 20+ installed     (optional — only to seed package.json devDeps from public npm)
     🟢 ✓ pnpm installed         (optional — same)
    
     🟢 checks passed. Ready to proceed.
    

    If git is missing, print its → Fix: line and STOP. Node/pnpm are optional here — if absent, note them as n/a (devDeps seed deferred to build skills) rather than failing.

  3. Read shared/naming-conventions.md — the derived-identifier table is canonical, including the Module-suffix rule for the native module symbol. Derive all file paths and class names from §2 of the PRD using that table; do not invent.

  4. Read shared/ppmplugin-format.md — §2 (the runtime dispatch contract: <name>/<receiver>NativeModules.<nativeModule>.<method>, where <nativeModule> = <Pascal>Module) and §4 (the upload-compatibility checks that the native module symbol must satisfy). The native modules this skill emits dispatch straight off that contract — there is NO TS INativeExtension layer mediating; see §3.3 below.

  5. Read shared/repo-layout.md — the exact tree, file list, and package.json shape to emit.

  6. Read ./PRD.md from the current working directory. If missing or empty, STOP with BLOCKED: PRD.md not found — run /design-native-extension-feature first.

  7. Read ./.extension-state.md if present. If the phase shows scaffold-complete, ask the user whether to regenerate (with confirm — overwrites files), resume (only fill in missing files), or abort.

The structural patterns this skill needs to emit (iOS module shape, Android module shape, podspec, package.json) are fully prescribed in this SKILL.md (§3.1–§3.7) and in shared/repo-layout.md. Do NOT fetch the reference extension repo at runtime — its lessons are already encoded here, and fetching it would risk copying PDF-specific code into a non-PDF extension.

If any read fails, STOP and report which file is missing.


Step 2 — Confirm the scaffold plan with the user

Print a concise summary derived from the PRD, then gate on approval before any write.

Scaffold plan
─────────────
Repo: powerapps-<kebab>
package: <kebab>-control  (dev-only, private — not published)
Class: <Pascal>
Native module: <Pascal>Module  → NativeModules.<Pascal>Module  (== ./manifest.json receivers[].nativeModule)
iOS class: RCT<Pascal>Module  (+moduleName returns <Pascal>Module)
Android module: <Pascal>Module  (com.powerapps.<lower>)
Podspec: <Pascal>Extension.podspec (optional, system-frameworks-only)
Dispatch contract: ./manifest.json (committed — written by this skill; read by the PCF + build stage)

Frameworks
  iOS:     <list from ARCHITECTURE §1.2>
  Android: <list from ARCHITECTURE §1.3>

Operations (<count from PRD §4>): <comma-separated names>
Pattern: <one-shot | streaming | two-way>
Error codes: <count from ARCHITECTURE §5>

Target directory: <cwd> (writes <N> files; no existing files will be overwritten without confirm)
Distribution: the compiled `.ppmplugin` bundle (built later by /generate-ppmplugin). This skill is purely local — no remote, no feed, no registry.

Use AskUserQuestion (single-select):

Proceed with this scaffold?

  • Yes — generate the files (recommended): write the control's sources into the current directory. This skill does not run git — no git init, no staging, no commit (the control lives in your existing repo; you commit when you're ready).
  • Edit the PRD first — exit; user re-runs /design-native-extension-feature to adjust.
  • Cancel

Step 3 — Generate the files

Write files in the order below. After each top-level group, print a one-line progress update (✓ wrote ios/ (3 files)). Don't dump file contents — the user sees the diff via the IDE.

Every file path is relative to the current working directory (the repo root). Names are derived per shared/naming-conventions.md.

3.1 Top-level repo files

Write:

  • .gitignore — emit exactly the following entries:

    • Node: node_modules/, dist/, build/
    • .ppmplugin build staging — MANDATORY: ppmplugin/ (the gitignored staging dir where /generate-ppmplugin writes the staged copy of the manifest, the binaries, and the final bundle — never committed. NOTE: the committed source-of-truth manifest.json lives at the repo root (./manifest.json, written below), NOT under ppmplugin/ — do not gitignore it; see shared/ppmplugin-format.md §1)
    • OS / editor: .DS_Store, .idea/, .vscode/
    • Claude Code local state (per-user, not shared): .claude/
    • Env: .env* (but allow !.env.example)
    • iOS build: Pods/, *.xcworkspace, DerivedData/, *.xcodeproj/xcuserdata/
    • Android build: *.iml, .gradle/, local.properties, captures/, .externalNativeBuild/, .cxx/
    • PCF build dirs only — NOT the pcf/ folder itself; source files (index.ts, ControlManifest.Input.xml, package.json, pcfconfig.json, etc.) stay tracked: pcf/**/{out,Solutions,node_modules,obj,bin,generated}/
    • Test-harness artifacts: test-harness/*.msapp
    • Skill-generated backups: *.bak.* (skills that replace tracked content may save a timestamped backup; those are intentionally local-only)
    • Design-time previews: .pcf-preview/ (HTML mockup of the PCF as it appears in Canvas Studio — written by /design-native-extension-feature Step 8.0 for visual review; regenerated each design iteration; not a source-of-truth artifact)
  • package.json — per the dev-only shape in shared/repo-layout.md §"package.json shape (dev-only)". Fill in name (a plain local name, e.g. <kebab>-control) and description from the PRD. version starts at 0.1.0. Set "private": true.

    This manifest is never published — no publishConfig, no feed registry, no files array, no main/types, no .npmrc. Its only job is to pin the React Native version the native builds compile against:

    {
      "name": "<kebab>-control",
      "version": "0.1.0",
      "private": true,
      "description": "<from PRD §1>",
      "devDependencies": {
        "react": "18.2.0",
        "react-native": "0.79.7"
      }
    }
    

    The react-native devDep supplies the iOS headers (/build-ios-binary) and pins the react-android coordinate the Android build resolves (/build-android-binary); add any other build-time devDeps the native modules need. All deps resolve from the public npm registry — there is no internal feed.

  • manifest.json (repo root, committed — the dispatch-contract source of truth) — author it now from the names this scaffold emits, per shared/ppmplugin-format.md §2 (schema) + §3 (derivation). This is the single artifact the Companion PCF (/generate-pcf-companion) and the build stage (/generate-ppmplugin-manifest) both read; authoring it here, next to the native code it describes, is what keeps the composite key <name>/<receiver> from drifting between the PCF and the module. Fields:

    • name = kebab(<Pascal>) of the class name (not the repo/capability name) — e.g. class PenInputpen-input.
    • version = the package.json version (0.1.0).
    • abi = { "compatibleShells": ">=1.0.0", "builtAgainst": "1.0.0" } (default; the build skills don't change it).
    • receivers[] = a single entry { "name": "<Pascal>Extension", "nativeModule": "<Pascal>Module", "methods": [<every @ReactMethod / RCT_EXPORT_METHOD name emitted in §3.4 / §3.5>] }. nativeModule MUST equal Android getName() and the iOS +moduleName return value — the Module-suffixed name (the reserved-name dodge).
    • entrypoints = declare every platform this scaffold generated (so the committed manifest is the full contract; the build stage trims it to the shipped target):
      • Android → "android": { "dex": "<Pascal>Plugin.dex", "packageClass": "com.powerapps.<lower>.<Pascal>Package" }
      • iOS → "ios": { "framework": "<Pascal>Plugin", "moduleClass": "RCT<Pascal>Module" }

    This is a logical contract, not a built artifact — it lists the platforms the module supports; the per-platform binaries are compiled later and the staged copy under ppmplugin/staging/ is reconciled down to whatever actually ships. Do NOT emit any entrypoints.js / extension.hbc / extensionClassName / jsLayer field — those are SDK-era leakage /audit-ppmplugin rejects. (The build stage re-runs the full validator on this file, so a malformed manifest is caught either way — but emit it correctly here.)

  • README.md — one-page user-facing doc tailored to the control. Sections: "What's in the box" (the compiled .ppmplugin bundle + PCF companion), "Build" (run /generate-ppmplugin to produce the .ppmplugin), "Architecture" (a Mermaid-or-ASCII diagram of Canvas formula → PCF → wrap-bridge → NativeModules.<Pascal>Module), "Development" (pnpm install to seed devDeps; native code is compiled by the build skills, not here), "Reference docs" (link to shared/ppmplugin-format.md). Use the PRD's §1 Summary verbatim. Drive every section from the PRD — never inject example values, prose, or screenshots from any other control's README.

  • CHANGELOG.md — single entry:

    # Changelog
    
    ## 0.1.0 — <ISO date>
    
    - Initial scaffold for <Human-Readable Name> native control.
    - Generated by pam-native-extensions plugin from PRD.md.
    
  • LICENSE — MIT.

3.2 The podspec (optional, at repo root)

Write <Pascal>Extension.podspec at the repo root (NOT inside ios/) only if ARCHITECTURE §1.2 names additional iOS system frameworks the module links. The .ppmplugin iOS build (/build-ios-binary) compiles from a throwaway staged Xcode project and does NOT npm-autolink against this podspec — so it lists system frameworks only (no React-Core / RN-CLI autolink dependency, no remote source). It exists for local pod lib lint convenience, not the bundle build. Template:

require "json"

package_json = JSON.parse(File.read(File.join(__dir__, "package.json")))

Pod::Spec.new do |s|
  s.name         = "<Pascal>Extension"
  s.version      = package_json["version"]
  s.summary      = "<one-line description from PRD>"
  s.description  = <<-DESC
    <2-3 sentence description from PRD — what it does, what it bridges to>
  DESC
  s.license      = "MIT"
  s.author       = { "Author" => "" }
  s.platform     = :ios, "<min-deployment-target from ARCHITECTURE §1.2>"
  s.source       = { :path => "." }
  s.source_files = "ios/**/*.{h,m}"   # change to {h,m,swift} if Swift used
  s.frameworks   = <comma-quoted list of SYSTEM frameworks from ARCHITECTURE §1.2>
  # No React-Core dependency: the .ppmplugin build resolves RN headers from the
  # react-native devDep in package.json, not via CocoaPods autolinking.
end

3.3 No TypeScript layer — the dispatch contract

This is the native-only track: there is no src/ TypeScript layer, no src/<Pascal>Extension.ts, no src/types.ts, no INativeExtension / handleMessageAsync implementation, and no sendAsync transport. (Those belong to the first-party SDK track — NOT in this track.) Do NOT generate any of them; reintroducing a TS contract layer here produces SDK-era leakage that /audit-ppmplugin rejects.

The contract instead is the manifest's runtime dispatch (shared/ppmplugin-format.md §2): the wrap host routes a call by the composite key <name>/<receiver> straight to NativeModules.<Pascal>Module.<method>(args, promise). There is no JS mediator. This means:

  • The request shape (the args object) and response shape (the object the promise resolves with) from ARCHITECTURE §4 are realized directly in the native @ReactMethod / RCT_EXPORT_METHOD signatures + their JSON responses — see §3.4 (iOS) and §3.5 (Android). The per-operation JSON parsing, request validation, operation branching, and error-code responses that a first-party TS handleMessageAsync would have done are emitted inside each native method instead. That dispatch logic is the valuable part this skill generates.
  • The manifest.json that declares name, receivers[].method, and receivers[].nativeModule (= <Pascal>Module) is written by this skill at the repo root (§3.1) — the native module symbols it emits and the manifest's receivers[] are authored together, so they can't disagree. /generate-ppmplugin-manifest later validates + reconciles + stages this file rather than re-authoring it (§2/§3 below + shared/ppmplugin-format.md §3).
  • The error-code set from ARCHITECTURE §5 is realized as the string codes the native errorJson(code, message) helpers emit (§3.4 / §3.5), each paired with a human-readable message — there is no TS error-union type to declare. These codes are the stable strings from the canonical catalog shared/error-codes.md (Canvas formulas branch on them, so they must not drift); emit exactly the catalog spelling for any code ARCHITECTURE §5 reuses. The PCF reads both: the error code to branch on, the message to surface as its ErrorMessage output.

3.4 iOS (ios/)

Write:

  • ios/RCT<Pascal>Module.h — minimal Obj-C header importing <React/RCTBridgeModule.h>, declaring @interface RCT<Pascal>Module : NSObject <RCTBridgeModule> @end.

  • ios/RCT<Pascal>Module.m — the implementation. Generate complete working code, not TODO placeholders. For each operation in PRD §4, the per-operation §3. block prescribes every implementation decision (framework, hosting, key APIs, export shape, edge case handling). Generate the implementation verbatim from §3.:

    • Imports: include RCT<Pascal>Module.h, UIKit, plus every framework named in ARCHITECTURE §3.'s "Framework / class" field for any operation (e.g. #import <PencilKit/PencilKit.h> if any §3. names PencilKit).
    • Module identity: do NOT emit RCT_EXPORT_MODULE(...) in a wrap plugin framework. That macro registers via +load and _RCTRegisterModule, which is not visible to the framework's dlopen flat namespace. Instead emit a class method + (NSString *)moduleName { return @"<Pascal>Module"; } — the Module-suffixed name. The Obj-C class name stays RCT<Pascal>Module (matching entrypoints.ios.moduleClass), while +moduleName MUST equal the manifest's receivers[].nativeModule and JS sees NativeModules.<Pascal>Module. Do NOT strip the suffix.
    • + (BOOL)requiresMainQueueSetup returning NO unless any §3. requires main-thread init.
    • init safety — the module is instantiated eagerly at load via [cls new], so init MUST NOT throw or do heavy/side-effecting work (ppmplugin-format §5). Do not acquire hardware, register NSNotification/KVO observers, or touch AVCaptureSession/CLLocationManager in init — defer to the first RCT_EXPORT_METHOD call (lazy), and wrap any unavoidable init work in @try/@catch. An uncaught exception in init crashes the host at launch (the iOS analogue of the Android Looper-less-Handler crash).
    • For each operation, write an RCT_EXPORT_METHOD taking exactly one NSDictionary *request parameter, then RCTPromiseResolveBlock resolve, RCTPromiseRejectBlock reject — e.g. RCT_EXPORT_METHOD(capturePenInput:(NSDictionary *)request resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject). This matches the wrap dispatch contract: the PCF sends args: [request] (a one-element array) spread positionally, so the method's first positional param is the request dictionary (ppmplugin-format §2). Read fields off request (request[@"…"]); do NOT expand into multiple positional params. Also: the Obj-C class MUST instantiate via a no-arg [cls new] after the runtime loads it — don't add a custom designated initializer that takes arguments. The body implements §3.'s iOS spec completely:
      • The hosting setup ("dedicated UIViewController presented modally, full-screen" → emit a UIViewController subclass or inline VC + presentViewController:animated:completion:). The presented VC's viewDidLoad MUST constrain custom content views to view.safeAreaLayoutGuide, not view directly — this prevents content from intruding under the notch / Dynamic Island / home indicator. Set modalPresentationStyle = UIModalPresentationFullScreen (or .pageSheet per ARCHITECTURE §3.). Add a UINavigationBar with Done / Cancel UIBarButtonItems for clear action affordance — same Material-toolbar-equivalent pattern as Android.
      • The key API calls in the order §3. specifies (e.g. PKCanvasView init, PKToolPicker attachment, drawing capture)
      • Each Done/Cancel/dismiss handler as §3. specifies
      • The export step as §3.'s "Export" line specifies (e.g. drawing.image(from: canvas.bounds, scale: 2.0) → PNG → base64)
      • Each edge case from §3.'s "Edge cases handled" list, with the exact behavior named (e.g. "User taps Cancel → resolve with USER_CANCELLED")
    • Threading: background work on dispatch_get_global_queue; UI presentation on dispatch_get_main_queue. Long-running native work must not block the JS thread.
    • Error helper: emit - (NSString *)errorJsonWithCode:(NSString *)code message:(NSString *)message that builds the dict @{@"status": @"error", @"error": code, @"message": (message ?: @"")} and serializes it via NSJSONSerialization — the SAME serializer as the success helper. Do NOT use stringWithFormat: a message (or code) containing a ", \, or newline would emit invalid JSON, which the PCF's response parse would surface as a misleading PARSE instead of the real failure — defeating the whole point of the message. The message is a human-readable diagnostic that makes the failure debuggable from the PCF without a native debugger: for a caught exception pass error.localizedDescription; for a validation failure a specific reason (e.g. @"missing required field 'uri'"); for USER_CANCELLED a short note. Every error path calls this with BOTH a code and a message — never a bare code.
    • Success helper: emit - (NSString *)successJsonWith:(NSDictionary *)result that builds {"status":"ok","result":<result>} via NSJSONSerialization.
    • Error propagation — wrap the operation body so every failure reaches the PCF with a code AND a message. Any framework/runtime failure must resolve with errorJsonWithCode:message: carrying a specific code and reason — never throw an uncaught Obj-C exception, crash, or resolve empty. Use @try/@catch around risky synchronous work and resolve the @catch with INTERNAL_ERROR plus exception.reason.
    • UI hygiene boilerplate for each presented UIViewController's viewDidLoad (mirrors Android's insets handling — prevents the most common iOS issue: content under safe areas, status bar, home indicator):
      - (void)viewDidLoad {
          [super viewDidLoad];
          self.view.backgroundColor = [UIColor systemBackgroundColor];
      
          // Navigation bar with Done / Cancel — equivalent to Android's MaterialToolbar.
          UINavigationBar *navBar = [[UINavigationBar alloc] init];
          navBar.translatesAutoresizingMaskIntoConstraints = NO;
          UINavigationItem *navItem = [[UINavigationItem alloc] initWithTitle:@"<Human-readable from PRD §2>"];
          navItem.leftBarButtonItem = [[UIBarButtonItem alloc]
              initWithBarButtonSystemItem:UIBarButtonSystemItemCancel
              target:self action:@selector(handleCancel)];
          navItem.rightBarButtonItem = [[UIBarButtonItem alloc]
              initWithBarButtonSystemItem:UIBarButtonSystemItemDone
              target:self action:@selector(handleDone)];
          navBar.items = @[navItem];
          [self.view addSubview:navBar];
      
          // Content view — the operation-specific surface (e.g. PKCanvasView, AVCaptureVideoPreviewLayer host).
          // Constrain to safeAreaLayoutGuide so content doesn't extend under the notch / home indicator.
          UIView *contentView = [[UIView alloc] init];   // Replace with operation-specific view per ARCHITECTURE §3.<n>
          contentView.translatesAutoresizingMaskIntoConstraints = NO;
          [self.view addSubview:contentView];
      

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-native-extension
Source
github.com/microsoft/power-platform-skills