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 i...
Scanned 9/3/2026
Install to Claude Code
npx -y skills add microsoft/power-platform-skills --skill generate-native-extension --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Generate Native Extension?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/microsoft-generate-native-extension)More formats (shields.io, HTML) on the badges page.
---
name: generate-native-extension
description: "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."
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Skill
model: opus
---
# /generate-native-extension
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`](../../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`](../../shared/ppmplugin-format.md)). 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`](../../shared/shared-instructions.md).
2. Apply the **per-skill minimal prereq policy** ([`shared-instructions.md §1.5`](../../shared/shared-instructions.md)). This track is **self-contained** ([`shared-instructions §0a`](../../shared/shared-instructions.md)) 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`](../../shared/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`](../../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`](../../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`](../../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`](../../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`](../../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`](../../shared/ppmplugin-format.md))
- 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`](../../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:
```json
{
"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`](../../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 `PenInput` → `pen-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:
```markdown
# 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:
```ruby
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`](../../shared/ppmplugin-format.md)): 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`](../../shared/ppmplugin-format.md)).
- 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`](../../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.<n> block prescribes every implementation decision (framework, hosting, key APIs, export shape, edge case handling). Generate the implementation verbatim from §3.<n>:
- Imports: include `RCT<Pascal>Module.h`, `UIKit`, plus every framework named in ARCHITECTURE §3.<n>'s "Framework / class" field for any operation (e.g. `#import <PencilKit/PencilKit.h>` if any §3.<n> 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.<n> 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`](../../shared/ppmplugin-format.md)). 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`](../../shared/ppmplugin-format.md)). 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.<n>'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.<n>). Add a `UINavigationBar` with Done / Cancel `UIBarButtonItem`s for clear action affordance — same Material-toolbar-equivalent pattern as Android.
- The key API calls in the order §3.<n> specifies (e.g. `PKCanvasView` init, `PKToolPicker` attachment, drawing capture)
- Each Done/Cancel/dismiss handler as §3.<n> specifies
- The export step as §3.<n>'s "Export" line specifies (e.g. `drawing.image(from: canvas.bounds, scale: 2.0)` → PNG → base64)
- Each edge case from §3.<n>'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):
```objc
- (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];
[NSLayoutConstraint activateConstraints:@[
[navBar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[navBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[navBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[contentView.topAnchor constraintEqualToAnchor:navBar.bottomAnchor],
[contentView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor],
[contentView.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor],
[contentView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
]];
}
- (UIStatusBarStyle)preferredStatusBarStyle {
// Adapt to system appearance — matches Android's windowLightStatusBar in light theme.
return UIStatusBarStyleDefault; // automatic light/dark per system
}
```
- Modal helper: emit `- (UIViewController *)topViewController` if any operation presents modally:
```objc
- (UIViewController *)topViewController {
UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController;
while (root.presentedViewController) { root = root.presentedViewController; }
return root;
}
```
**No TODO placeholders. No `// implement this`.** If a §3.<n> block is incomplete (any "Key APIs and decisions" item is vague or missing), STOP with `NEEDS_CONTEXT: ARCHITECTURE §3.<n> implementation block is incomplete — re-run /design-native-extension-feature Step 7 (per-operation implementation walkthrough) to complete it`. Don't paper over a vague spec with a guess.
### 3.5 Android (`android/`)
Write:
- **`android/build.gradle`** — **library-only** gradle config. The module is consumed by the host's managed build, which provides the root project setup. (For the standalone `.ppmplugin` build, `/build-android-binary` compiles from a throwaway staged copy with pinned versions — this canonical file is never edited; see [`shared/ppmplugin-format.md §5`](../../shared/ppmplugin-format.md).) Do NOT emit a `buildscript { ... }`, `allprojects { ... }`, or any classpath declarations — those belong to the root project, not this library module.
Library-only shape (this is the entire file — no preamble, no root-project blocks):
```gradle
// <kebab>-control
// Android library module — consumed by the host's managed build.
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
android {
namespace "com.powerapps.<lower>"
compileSdkVersion safeExtGet('compileSdkVersion', 35)
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', <PRD min — default 24>)
targetSdkVersion 35
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = '17' }
}
dependencies {
// 'react-android' (renamed from 'react-native' in RN 0.73). compileOnly + pinned:
// the wrap shell provides RN at runtime, so never bundle it, and the legacy
// 'react-native:+' coordinate does not resolve in the standalone build.
// Read <rnVersion> from package.json devDependencies (currently 0.79.7).
compileOnly "com.facebook.react:react-android:<rnVersion>"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0' // WindowCompat / WindowInsetsCompat for UI hygiene
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' // for the generated layout XML
implementation 'com.google.android.material:material:1.11.0' // Material 3 theme + components
// Plus any ARCHITECTURE §1.3 / §1.4-specified additions (e.g. ML Kit, FusedLocationProvider)
}
```
- **Files NOT to generate** (these are root-project / standalone-build concerns; the host's managed build — or, for the `.ppmplugin`, `/build-android-binary`'s staged copy — owns them):
- `android/settings.gradle` — root project's responsibility
- `android/gradle.properties` — root project's properties; `android.useAndroidX` and `android.enableJetifier` are supplied ambiently by the host (and generated into the staged copy by `/build-android-binary`), not by the library
- `android/gradlew` + `android/gradle/wrapper/*` — the Gradle wrapper; library modules don't need their own wrapper
- Any top-level `buildscript { ext, repositories, dependencies (classpath) }` block in `build.gradle` — the host provides AGP + Kotlin classpaths
> **No standalone build script.** The `android/` directory is consumed by the host's managed build (and copied into a pinned staging dir by `/build-android-binary` for the `.ppmplugin`); it doesn't have to compile in isolation. Don't add a top-level `buildscript { ... }` / `allprojects { ... }` block — the host provides those. Standalone `./gradlew assembleDebug` against this directory is **not** a validation path we support (native compile happens in `/build-android-binary`, not here — see [`shared/ppmplugin-format.md §5`](../../shared/ppmplugin-format.md)).
- **`android/src/main/AndroidManifest.xml`** — registers permissions from ARCHITECTURE §1.4 AND the dedicated capture Activity (if ARCHITECTURE §3.<n> hosts in one) with a Material 3 theme:
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.powerapps.<lower>">
<!-- One <uses-permission android:name="..." /> per entry in ARCHITECTURE §1.4 Android permissions -->
<application>
<!-- One <activity> per ARCHITECTURE §3.<n> that hosts in a dedicated Activity.
Theme references generated themes.xml; screenOrientation per ARCHITECTURE §3.<n>. -->
<activity
android:name=".<Pascal>CaptureActivity"
android:theme="@style/Theme.<Pascal>"
android:screenOrientation="portrait"
android:exported="false" />
</application>
</manifest>
```
- **`android/src/main/res/values/themes.xml`** — Material 3 theme so all components render with proper Material styling, not the bare AppCompat defaults. Without this, generated UIs hit issues like status bar overlap and unthemed buttons.
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.<Pascal>" parent="Theme.Material3.DayNight.NoActionBar">
<!-- System bars: drawn by the OS but content extends behind them; the Activity applies insets. -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">true</item>
</style>
</resources>
```
- **`android/src/main/res/layout/activity_<lower>_capture.xml`** — root layout uses Material components. Toolbar at top, bounded content area in a `MaterialCardView` (drawing surface, camera preview, etc.):
```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res/auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/<lower>_capture_menu"
app:navigationIcon="@drawable/ic_close"
app:title="<Human-readable name from PRD §2>" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/content_card"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- The operation-specific surface goes here: drawing View, camera SurfaceView,
photo preview, etc. — substituted per ARCHITECTURE §3.<n>'s "Hosting" specification. -->
<View
android:id="@+id/capture_surface"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurfaceContainerLowest" />
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
```
- **`android/src/main/res/menu/<lower>_capture_menu.xml`** — toolbar action items. **`action_done` is MANDATORY** for any capture-flow operation; without it the user has no way to submit. Additional actions (Clear, Undo, etc.) per ARCHITECTURE §3.<n>'s UI actions:
```xml
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res/auto">
<!-- MANDATORY for capture flows. NEVER omit Done — user cannot complete the operation otherwise. -->
<item
android:id="@+id/action_done"
android:title="@string/action_done"
app:showAsAction="always" />
<!-- Optional: one <item> per additional toolbar action declared in ARCHITECTURE §3.<n>
(e.g. Clear All, Undo). Set app:showAsAction="ifRoom" for non-critical actions. -->
</menu>
```
- **For multi-mode capture operations (pen/eraser, photo/video, etc.):** the toolbar / mode-selection row uses `MaterialButtonToggleGroup`, not plain `Button`s. Toggle group provides the active-state visual feedback the user needs to know which mode is currently selected. Example layout fragment to include in `activity_<lower>_capture.xml`:
```xml
<!-- Insert into the toolbar or just below it, when ARCHITECTURE §3.<n> has multiple modes. -->
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/mode_toggle_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<!-- One <Button style="?attr/materialButtonOutlinedStyle"> per mode in ARCHITECTURE §3.<n>.
Example for pen/eraser/clear: -->
<Button android:id="@+id/mode_pen" android:text="@string/mode_pen" style="?attr/materialButtonOutlinedStyle" />
<Button android:id="@+id/mode_eraser" android:text="@string/mode_eraser" style="?attr/materialButtonOutlinedStyle" />
</com.google.android.material.button.MaterialButtonToggleGroup>
```
And wire the listener in the Activity's `onCreate`:
```kotlin
val toggleGroup: MaterialButtonToggleGroup = findViewById(R.id.mode_toggle_group)
toggleGroup.check(R.id.mode_pen) // default
toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
when (checkedId) {
R.id.mode_pen -> captureSurface.setMode(<Pascal>Mode.PEN)
R.id.mode_eraser -> captureSurface.setMode(<Pascal>Mode.ERASER)
}
}
```
Without this, the user sees a row of identical-looking buttons and has no idea which mode is active. Confirmed UX-blocking failure mode in v0 extensions.
- **`android/src/main/res/values/strings.xml`** — string resources for the menu items + content descriptions (accessibility):
```xml
<resources>
<string name="action_done">Done</string>
<!-- Plus one entry per ARCHITECTURE §3.<n> action; one content-description per accessible element. -->
</resources>
```
- **`android/src/main/java/com/powerapps/<lower>/<Pascal>Module.kt`** — Kotlin native module. **Generate complete working code, not TODO placeholders.** For each operation, the per-operation §3.<n> block's "Android implementation" sub-section prescribes every implementation decision. Generate the implementation verbatim from §3.<n>:
- Class: extends `ReactContextBaseJavaModule`.
- `getName()` returns `"<Pascal>Module"` — the **`Module`-suffixed** name (matches `NativeModules.<Pascal>Module` on JS side, the iOS `+moduleName` return value, and the manifest's `receivers[].nativeModule`). Do NOT strip the suffix — it's the reserved-name dodge (see [`shared/ppmplugin-format.md §4`](../../shared/ppmplugin-format.md)).
- For each operation, write a `@ReactMethod` function taking **exactly one `ReadableMap request` parameter** followed by `Promise promise` — e.g. `@ReactMethod fun capturePenInput(request: ReadableMap, promise: Promise)`. This matches the wrap dispatch contract: the PCF sends `args: [request]` (a one-element array) and the proxy does `fn.apply(mod, [request])`, so the method receives the request object as its single positional param ([`ppmplugin-format §2`](../../shared/ppmplugin-format.md)). Read each field off `request` (`request.getString("…")`, `request.getInt("…")`, etc.); do NOT expand the request into multiple positional params. The body implements §3.<n>'s Android spec **completely**:
- The hosting (dedicated `Activity` via `Intent`, or `Fragment`, or in-place — whatever §3.<n> specifies)
- The key API calls in the order §3.<n> specifies (e.g. `View.onTouchEvent` registration; `Path` accumulation; stylus pressure handling)
- Each Done/Cancel handler as §3.<n> specifies
- The export step as §3.<n>'s "Export" line specifies (e.g. render to `Bitmap`, compress to PNG, base64-encode)
- Each edge case from §3.<n>'s "Edge cases handled" list, with the exact behavior named
- If §3.<n> requires a dedicated `Activity`, emit it as a separate `.kt` file under the same package (e.g. `<Pascal>CaptureActivity.kt`) and register it in `AndroidManifest.xml`. The Activity's `onCreate` MUST emit the following UI hygiene boilerplate so the generated UI doesn't suffer from status bar overlap, missing Material theming, or rotation issues (these were repeat issues in v0 extensions):
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Edge-to-edge layout; we apply system-bar padding ourselves below.
WindowCompat.setDecorFitsSystemWindows(window, false)
setContentView(R.layout.activity_<lower>_capture)
// Pad root by status/nav bar insets so toolbar doesn't sit UNDER the status bar.
// This is the fix for the most common Android UI bug in PAM extensions:
// "buttons overlapping with system clock / status icons".
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root)) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
// Toolbar with Done/Cancel via Material menu.
val toolbar: MaterialToolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
toolbar.setNavigationOnClickListener { onCancelled() } // navigation icon = Cancel
// Wire up the operation-specific surface (drawing View, camera preview, etc.)
// — per ARCHITECTURE §3.<n>'s "Hosting" + "Key APIs and decisions" specification.
val captureSurface: <PRD-§3.<n>-View-class> = findViewById(R.id.capture_surface)
// ... operation-specific setup per §3.<n> ...
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.<lower>_capture_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_done -> { onDone(); true }
// Plus one branch per additional toolbar action declared in §3.<n>.
else -> super.onOptionsItemSelected(item)
}
}
```
Required imports: `androidx.core.view.WindowCompat`, `androidx.core.view.ViewCompat`, `androidx.core.view.WindowInsetsCompat`, `com.google.android.material.appbar.MaterialToolbar`.
- **Constructor / `init{}` safety — the module is built eagerly at bridge startup on a possibly Looper-less thread, so construction MUST NOT throw** ([`ppmplugin-format §5`](../../shared/ppmplugin-format.md)). Keep the constructor cheap: do NOT register system callbacks, acquire camera/sensor/location managers, or do file I/O in the constructor / `init{}` — **defer them to the first `@ReactMethod` call (lazy init)**. If a listener genuinely must be registered at construction, pass an **explicit `Handler(Looper.getMainLooper())` — never `null`** (a `null` or implicit `Looper` throws `Can't create handler inside thread that has not called Looper.prepare()`), and wrap it in try/catch so a subsystem hiccup leaves the module in a safe default state instead of crashing the host at launch. Example: `private val mainHandler = Handler(Looper.getMainLooper())`, then `try { cameraManager?.registerTorchCallback(cb, mainHandler) } catch (e: Exception) { /* best-effort */ }`. Mirror the same discipline in `invalidate()` (unregister in try/catch).
- **Runtime permissions (dangerous perms only).** For any op whose ARCHITECTURE §1.4 lists a *dangerous* Android permission (`CAMERA`, `RECORD_AUDIO`, `ACCESS_FINE/COARSE_LOCATION`, …), the `@ReactMethod` MUST check `ContextCompat.checkSelfPermission(...)` before calling the API and, if not granted, `promise.resolve(errorJson("PERMISSION_DENIED", "<permission> not granted"))` (a declared §5 code) rather than let the framework throw `SecurityException`. Declaring it in `AndroidManifest.xml` is necessary but **not** sufficient — API 23+ requires the runtime grant.
- **`currentActivity` nullability.** Any method that presents UI / starts an Activity MUST guard `val activity = currentActivity ?: return promise.resolve(errorJson("NO_ACTIVITY", "no foreground activity"))` — `currentActivity` is `null` when the app is backgrounded, and dereferencing it NPE-crashes the host.
- Threading: heavy work in a coroutine (`CoroutineScope(Dispatchers.IO).launch { ... }`) or `Thread { ... }.start()`; UI work back via `Handler(Looper.getMainLooper()).post { ... }` or `withContext(Dispatchers.Main)`.
- Response JSON: must mirror iOS exactly. Both platforms must produce byte-identical JSON for the same input — this is non-negotiable, scaffold MUST verify by visual inspection of the two implementations after writing them.
- Helper `private fun errorJson(code: String, message: String): String` building `{"status":"error","error":"<code>","message":"<message>"}` via **`JSONObject`** (the same serializer as `successJson` — `JSONObject().put("status","error").put("error",code).put("message",message).toString()`). Do **NOT** build it with string interpolation: a `message` 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. The `message` is a **human-readable diagnostic**: for a caught exception pass `e.message ?: e.toString()`; for a validation failure a specific reason; for `USER_CANCELLED` a short note. This MUST mirror iOS byte-for-byte. **Every error path calls this with BOTH a code and a message.**
- Helper `private fun successJson(result: Map<String, Any?>): String` building `{"status":"ok","result":<json>}` via `JSONObject`.
- **Error propagation — every failure resolves the Promise with `errorJson(code, message)`, never a thrown/uncaught exception.** Wrap the operation body (and any coroutine/Thread) in try/catch; on catch, `promise.resolve(errorJson("INTERNAL_ERROR", e.message ?: e.toString()))`. A coroutine that throws without catching, or a path that never resolves the Promise, leaves the maker with a hung control and no code/message.
**No TODO placeholders.** If a §3.<n> block is incomplete, STOP with the same `NEEDS_CONTEXT:` error as iOS — don't write Android code from a vague spec.
- **`android/src/main/java/com/powerapps/<lower>/<Pascal>Package.kt`** — `ReactPackage` implementation that registers `<Pascal>Module`. **It MUST have a public no-arg constructor** — the wrap runtime instantiates it via `loadClass(packageClass).getDeclaredConstructor().newInstance()`, so a `ReactPackage` with only an arg-ed constructor throws `NoSuchMethodException` and the plugin silently fails to load ([`ppmplugin-format §5`](../../shared/ppmplugin-format.md)). The form below is correct — Kotlin gives `class <Pascal>Package : ReactPackage` an implicit no-arg constructor. **Do NOT add a constructor with parameters to the package class** (the *module* takes `reactContext` via `createNativeModules` — that's fine; the *package* must stay no-arg):
```kotlin
class <Pascal>Package : ReactPackage { // no-arg constructor required by the wrap runtime
override fun createNativeModules(reactContext: ReactApplicationContext) =
listOf<NativeModule>(<Pascal>Module(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext) =
emptyList<ViewManager<*, *>>()
}
```
### 3.6 Tests — none generated in this track
There is **no `tests/` directory** to write. The first-party SDK track validates conformance with TypeScript type-level fixtures (`*.test-d.ts` + `tsc --noEmit`) against the `INativeExtension` interface — but **that layer does not exist here** (§3.3), so there is nothing to type-check.
The third-party-control analogue is **structural contract verification done by [`/test-native-extension`](../test-native-extension/SKILL.md)**, not generated source: it greps the native modules' request/response/error usage against each other and against the manifest (the Android `getName()` ↔ iOS `+moduleName` ↔ `receivers[].nativeModule` agreement, the cross-platform JSON parity). That runs as a separate skill on the working tree — this skill does not emit test files for it. **No Jest, no `tsconfig`, no fixtures.**
### 3.7 State file
Write **`.extension-state.md`** using the template in [`shared/repo-layout.md`](../../shared/repo-layout.md) §"`.extension-state.md` template". Fill:
- **Identity** block — from PRD §2 plus the derived names (Capability, Class, Native module = `<Pascal>Module`, Android package, PRD path).
- **Phase**: `Last completed: scaffold` / `Next: /test-native-extension` / `Status: ok`.
- **`## ppmplugin (third-party controls)`** block — leave at its defaults (`Target: unset`, `Manifest: not written`, `Android DEX: not built`, `iOS framework: not built`, `Bundle: not assembled`). The `/generate-ppmplugin` build skills fill it.
If a `.extension-state.md` already existed and we agreed to "resume" in Step 1.7, only update the **Phase** block (leave `## ppmplugin` untouched — it captures any later build state).
---
## Step 3.8 — Self-critique against the proactive protocol
> **This step exists because the smoke check (Step 4) is structural only — it compiles nothing. Generated Kotlin and Obj-C don't compile-check here; they compile later in `/build-android-binary` (Gradle) and `/build-ios-binary` (xcodebuild). Runtime UI and UX bugs (toolbar overflows, dead-end modals, missing exit paths, asymmetric platform features) don't surface in *any* compile — only on a real device, often after the engineer has lost an afternoon. The self-critique protocol forces the skill to reason about its own output *before* declaring done, gate-by-gate, instead of trusting "looks fine."**
The protocol lives at [`shared/self-critique-protocol.md`](../../shared/self-critique-protocol.md). It is **proactive reasoning, not pattern matching** — each gate enumerates a category of thing (PRD-listed behaviors, user-reachable controls, mutually-exclusive modes, etc.) and forces specific questions about each enumerated item. Read the protocol file in full before running this step; what follows here is the integration contract.
### 3.8.1 Re-read every file emitted in this run
Fresh-read from disk. Files of interest:
- `android/src/main/java/com/powerapps/<lower>/<Pascal>Module.kt` (its `@ReactMethod` request/response/error surface — this IS the dispatch contract, since there is no TS layer)
- `android/src/main/java/com/powerapps/<lower>/<Pascal>CaptureActivity.kt` and any other Activities
- `android/src/main/res/**/*.xml` (layouts, menus, themes, manifest)
- `ios/RCT<Pascal>Module.{h,m}` (or `.swift` if ARCHITECTURE §1.2 chose Swift)
- `ios/*.podspec`
- `pcf/<Pascal>PCF/index.ts` and `ControlManifest.Input.xml` (if PCF was part of this run)
### 3.8.2 Walk the protocol gates in order
For each gate in the protocol — **PRD coverage**, **User journey**, **Layout feasibility**, **State coverage**, **Cross-platform parity**, **Reversibility**, **Asymptotic / lifecycle**, **Spec-drift**, **Runtime safety & lifecycle**, **Buildability & bundle-fit feasibility**, **PCF ↔ native round-trip contract** — execute the gate's "enumerate + ask" procedure as described in the protocol file.
**Gate 10 (Buildability) is not optional for this track.** It re-runs, over the *generated code*, the same reasoning `/design-native-extension-feature` Step 7.5 ran over the design — so a dependency, return shape, or construction cost that slipped past design (or was introduced during codegen) is caught here, before `/build-android-binary` / `/build-ios-binary` fail minutes later. **Gate 11 (PCF round-trip)** runs only when a PCF was emitted in this run; when the PCF is generated separately, `/generate-pcf-companion` Step 5.7 runs it instead.
**Do not short-circuit.** Each gate enumerates real items (PRD bullet points, user-reachable controls, horizontal container children, etc.) and answers a concrete question per item. Skipping a gate because "intuitively the code looks fine" defeats the purpose — the whole point is to force structured analysis where past intuition has failed.
### 3.8.3 Build the self-critique report
Aggregate findings across all gates. Each finding follows the protocol's structure:
```
- Gate: <name>
- File: <path>:<line range>
- Severity: blocker | concern
- Problem: <one sentence>
- Why this is wrong: <one sentence anchored in the gate's principle>
- Suggested fix: <what to change>
- Autofix: applied | proposed | requires human review
```
### 3.8.4 Apply auto-fixes inline
For generation context (this skill), the user has no intermediate review checkpoint between code-emit and Step 4 smoke check. The protocol's auto-fix policy translates to:
- **`autofix: applied`** (mechanical fix, no design choice) → apply silently, log to `.extension-state.md` under `Auto-fixes`.
- **`autofix: proposed`** (structural fix, unambiguous) → apply, surface in the final summary with a one-line note. The user can read `.extension-state.md` to see the diff against their expected output.
- **`autofix: requires human review`** (design judgment) → do NOT modify code. Promote to a top-level `NEEDS_CONTEXT` item in the final summary.
(Contrast with `/debug-extension` Step 7.5, which gates `proposed` fixes on explicit user confirm because it sits inside an interactive review cadence.)
### 3.8.5 Re-loop after fixes
If 3.8.4 applied any fixes, repeat from 3.8.1. A fix at one gate can either unblock or surface a new finding elsewhere. Cap the loop at 3 iterations; if findings remain after iteration 3, surface them as concerns regardless of severity.
### 3.8.6 Return-status impact
- All gates clean OR fixed → continue to Step 4. Run can return `DONE`.
- Concerns remain (acceptable per gate severity rules) → run returns `DONE_WITH_CONCERNS: <one-line summary>`. Log every concern in `.extension-state.md`.
- Any **blocker** remains → run returns `BLOCKED: self-critique found <list>`. Surface the report. The scaffold is not shippable until the user resolves blockers.
### What this step specifically catches
The protocol's gates are derived from first principles, not from a list of past failures. By construction, it should catch:
| Gate | Bug class |
|---|---|
| PRD coverage (Gate 1) | Behaviors promised in ARCHITECTURE §3.<n> not implemented in code (e.g. "Done button is anchored right" → no Done button at all). |
| User journey (Gate 2) | Modals with no exit, controls that don't respond, error paths that never resolve the Promise. |
| Layout feasibility (Gate 3) | Toolbar overflows on small screens, controls pushed off-edge, primary actions unreachable at narrow widths or high font scales. |
| State coverage (Gate 4) | Mode toggles without visible active state, error codes returned but not displayed, boolean toggles without observable UI. |
| Cross-platform parity (Gate 5) | iOS got 5 features, Android got 3 — with no PRD note explaining why. |
| Reversibility (Gate 6) | Destructive actions without confirmation, modals with one entry and no exit, multi-step flows with no back. |
| Asymptotic / lifecycle (Gate 7) | Rotation loses state but PRD says it shouldn't; primary action not idempotent under fast double-tap. |
| Spec-drift (Gate 8) | Code does things PRD doesn't document (drift the other direction from Gate 1). |
| Runtime safety & lifecycle (Gate 9) | Looper-less constructor `Handler` crashes the host at launch; a Promise path that never settles hangs the caller; an acquired listener/camera never released. |
| Buildability & bundle-fit (Gate 10) | A dependency that needs `compileSdk > 34` / AGP `8.x`, an iOS framework that can't ship flat, a non-JSON-serializable return, or a streaming pattern with no channel — caught before the toolchain fails. |
| PCF ↔ native round-trip (Gate 11) | PCF composite key ≠ manifest receiver, `cordova.exec` instead of `sendAsync`, a pre-stringified envelope, a bare `args` object, or a success path that doesn't unwrap the wrap `message` container — all silent-on-device otherwise. |
When a new failure mode is observed, the question to ask is **"which gate should have caught this?"** If the answer is "none of them" — that's a signal the protocol needs a new gate (or an existing gate's enumeration list needs expanding). Do NOT add a one-off pattern check that doesn't generalize.
This step is LLM reasoning over generated source. It is not a real compiler. The protocol is structured enough to force enumeration (and stop "looks fine" from being a valid answer), but it depends on the reasoning at each gate actually being careful. Pair with `/build-android-binary` / `/build-ios-binary` (real compile) and on-device verification for runtime errors the protocol can't simulate.
---
## Step 4 — Run a smoke check
There is **no TypeScript layer to compile** in this track, so the smoke check does NOT run `tsc`. Native code is compiled later by `/build-android-binary` and `/build-ios-binary` — not here. The smoke check is a fast **structural** pass (each is a few seconds):
**OS-neutral:** the `pnpm install` below is a real, cross-platform command; the symbol-agreement check is **read+parse — run it with the built-in Grep tool**, not shell `grep` (the bash is illustrative; `grep -R` isn't on Windows).
```bash
# 1. (Optional) seed the dev-only devDeps from the public npm registry. Skip if Node/pnpm absent.
pnpm install # resolves react + react-native (RN headers + react-android coordinate) for the build skills
# 2. Structural self-check (no compile) — use the Grep tool, not shell grep:
# confirm +moduleName returns <Pascal>Module in ios/ AND getName() == "<Pascal>Module" in android/.
grep -R '+ (NSString *)moduleName' ios/ && grep -R 'return @"<Pascal>Module"' ios/ # iOS +moduleName == <Pascal>Module
grep -R 'getName()' android/ | grep '"<Pascal>Module"' # Android getName() == <Pascal>Module
```
The `pnpm install` here pulls **only from the public npm registry** and requires no organization-specific package-feed authentication. It's optional: if it fails or Node/pnpm are absent, note it and continue (the build skills will seed devDeps when they run).
**Ordering precondition:** ensure `.gitignore` is on disk first (it is, per §3.1) — `pnpm install` populates `node_modules/`, and the `.gitignore` keeps it (and `ppmplugin/`) out of the repo when *you* later commit. This skill doesn't stage or commit anything, but a missing `.gitignore` would leave `node_modules/` showing as untracked. If §3.1 was skipped (e.g. resumed mid-flow), re-run from §3.1 before the smoke check.
If a step fails:
- For `pnpm install` failures: this is non-fatal here (only the build skills truly need the devDeps). Print the failing line, note `devDeps seed deferred`, and continue.
- For the symbol-agreement greps: a mismatch means iOS `+moduleName`, Android `getName()`, and the intended manifest `receivers[].nativeModule` have drifted. Fix so all three read `<Pascal>Module` and re-run. This is a real gate — the wrap host can't dispatch if they disagree.
DO NOT mark the scaffold as complete in `.extension-state.md` if the symbol-agreement check fails. Set `Status: blocked` with the failure reason.
---
## Step 5 — Summary
Print:
```
Scaffold complete
─────────────────
Directory: <cwd> (<N> files written — git untouched; commit when you're ready)
Native module: <Pascal>Module (== ./manifest.json receivers[].nativeModule — both authored here, in sync)
Dispatch contract: ./manifest.json written (committed source of truth; PCF + build stage read it)
Smoke check: devDeps seed <✓ | deferred> | native symbol agreement ✓
State file: ./.extension-state.md → Phase: scaffold / Next: /test-native-extension
Next steps
──────────
1. Review the generated implementations in ios/RCT<Pascal>Module.m and android/.../<Pascal>Module.kt against ARCHITECTURE §3.<n>. Customize cosmetic details (button styling, modal chrome) if you want — but the framework wiring should be working out of the box.
2. Run /test-native-extension for the structural contract pre-flight (native module shape, Android getName ↔ iOS +moduleName ↔ ./manifest.json agreement, cross-platform JSON parity). Native iOS / Android compile is NOT validated here — it happens in /build-android-binary and /build-ios-binary.
3. Run /generate-pcf-companion to generate the Companion PCF under pcf/ — it reads ./manifest.json for the composite key so the PCF and module stay aligned.
4. When ready to ship, run /generate-ppmplugin — it validates + stages ./manifest.json, compiles the native binaries, and assembles the .ppmplugin bundle for upload to Dataverse.
```
---
## Step 6 — Offer next-step skills
Per `shared/shared-instructions.md §9.1`, use `AskUserQuestion` with all plausible next skills as options (not a Yes/No), and include an escape-hatch option.
```
Question: "What would you like to do next?"
Header: "Next step"
Options:
1. "Run /generate-pcf-companion"
description: "Next major scaffold step in the build flow. Runs `pac pcf init` under pcf/ and writes the Companion PCF that dispatches on the composite key <name>/<receiver> read from ./manifest.json (authored here), cross-checked against ARCHITECTURE §4 + §8 + §9."
2. "Run /test-native-extension"
description: "Structural contract pre-flight (native module shape, Android getName ↔ iOS +moduleName ↔ ./manifest.json agreement, cross-platform JSON parity). Quick sanity check before adding more code. No compile."
3. "Run /generate-ppmplugin"
description: "Build the deliverable: validates + stages ./manifest.json, compiles the native binaries (DEX / iOS framework), and assembles the .ppmplugin bundle. Usually run after the PCF exists and the contract is settled."
4. "Stay — I'll review the generated code first"
description: "Skill exits. Run git diff to inspect the scaffold and decide what to run next yourself."
```
**For options 1, 2, or 3:** **invoke that skill via the `Skill` tool in the same turn** — selecting the option IS the request to run it (Execute, don't describe — shared-instructions §9.1 HARD RULE). Do NOT stop and tell the user to run it themselves. The invoked skill runs its own Step 1 prereq check + gates, so just hand off to it.
**For option 4 (stay):** print one line: `Scaffold complete. Run any of the suggested skills when you're ready.` Then proceed to return-status.
---
## Return-status protocol
The literal first line of your final message MUST be one of:
| Code | Meaning |
|---|---|
| `DONE` | All files written, smoke check (native symbol agreement) passed, state file updated. Next: `/test-native-extension`. |
| `DONE_WITH_CONCERNS: <list>` | Files written but the smoke check raised non-fatal warnings (e.g. devDeps seed deferred), OR a `// TODO` count higher than expected (PRD operations that are particularly complex), OR a derived-name collision was deferred to the user. |
| `NEEDS_CONTEXT: <missing>` | A required PRD section was incomplete; couldn't proceed without re-running `/design-native-extension-feature` on that section. |
| `BLOCKED: <reason>` | Prereq failed (`git` missing), a reserved-name / generic-noun collision on the native module symbol could not be resolved, or the native symbol-agreement check failed and could not be auto-fixed. |
After the first line, blank line, then the human-readable summary.
---
## Scope of this skill — generating vs auditing
This skill **generates** the extension repo from a PRD. It's not a linter; it doesn't audit existing working code against the new template. When run in a directory that already has a scaffold, the regenerate / resume / abort gate asks before overwriting — and "regenerate" is destructive (loses any manual edits to generated files).
**A diff between "what we'd generate now" and "what exists" is NOT a list of defects.** Existing code that builds (in `/build-android-binary` / `/build-ios-binary`) and produces correct runtime behavior is fine even if the code shape differs from the current template. Flag only:
- Code that won't compile in the build skills (malformed Kotlin / Obj-C)
- Code that violates the dispatch contract such that runtime breaks (native module symbol that doesn't match `receivers[].nativeModule`, wrong method signature the host can't dispatch, wrong response shape that PAM can't deliver)
- Cross-platform drift (iOS and Android emitting different request field names or response shapes for the same operation)
A locally-defined helper that does the same job as a template helper is NOT a defect. A method that was originally generated with a `// TODO` marker and has since been filled in by the engineer is the EXPECTED state — don't flag it as "different from the template."
## Hard rules — correctness (these must be true for the control to work)
- **There is NO TS `INativeExtension` layer.** The contract is the manifest's runtime dispatch (`<name>/<receiver>` → `NativeModules.<Pascal>Module.<method>`, [`shared/ppmplugin-format.md §2`](../../shared/ppmplugin-format.md)). Do NOT generate `src/<Pascal>Extension.ts`, `handleMessageAsync`, or a `sendAsync` transport — they're SDK-era leakage that `/audit-ppmplugin` rejects.
- **The native module symbol MUST be `<Pascal>Module` across all runtime surfaces.** iOS `+moduleName` returns `@"<Pascal>Module"`, Android `getName() = "<Pascal>Module"`, and the `./manifest.json` `receivers[].nativeModule` (authored here in §3.1, alongside these modules) MUST agree, and `<Pascal>Module` is what JS sees as `NativeModules.<Pascal>Module`. The Obj-C class name stays `RCT<Pascal>Module` and matches `entrypoints.ios.moduleClass`. Mismatch = the wrap host's dispatch finds nothing.
- **The native module symbol MUST pass the validator's reserved-prefix / reserved-name rules** ([`shared/ppmplugin-format.md §4`](../../shared/ppmplugin-format.md)). The `Module` suffix is the structural dodge for bare reserved names (`DeviceInfo` → `DeviceInfoModule`); do NOT strip it. If the derived symbol still hits a reserved prefix or the known reserved-name subset, STOP and have the user pick a vendor-prefixed class.
- **iOS and Android JSON response shapes MUST be byte-identical for the same input.** Both implementations are derived from the same §3.<n> response shape. Drift here causes maker-side branching bugs that are hard to diagnose.
- **Request/response field names MUST match what the manifest + PCF expect.** The field names each native method parses and emits must match the request/response shapes in ARCHITECTURE §4 (which the manifest and PCF both derive from). Drift = the PCF sends/reads the wrong fields.
- **The native symbol-agreement smoke check (Step 4) MUST pass** before marking `.extension-state.md` Phase = `scaffold`. On failure, set `Status: blocked` with the reason.
- **No invented APIs.** If §3.<n> specifies a framework/API you're uncertain exists on the named min-OS version, STOP and flag. Don't ship code referencing a method that doesn't exist.
## Recommended template style (preferences for new scaffolds; existing working code is fine)
These describe the cleanest shape for newly-generated code. They're how this skill renders fresh output. Existing code that achieves the same outcome differently is **not** in violation — don't flag stylistic differences as defects when auditing.
- **Prefer the `Module`-suffixed native symbol** (`<Pascal>Module`) consistently across iOS, Android, and the future manifest. It satisfies the reserved-name rule and keeps the three dispatch surfaces in lockstep.
- **Prefer complete implementations from ARCHITECTURE §3.<n>** rather than `// TODO: implement using <framework>` placeholders. Generated code should compile and run on the happy path out of the gate.
- **Prefer the pinned `react-native` devDep** from `package.json` for the build skills to resolve RN headers / the `react-android` coordinate, rather than `latest`. Reproducible builds.
## Things the skill enforces at generation time
When emitting new code from scratch, the skill follows the template above. Strict requirements:
- **No file overwrites without explicit confirm** — the regenerate / resume / abort gate at Step 2 is real.
- **STOP with `NEEDS_CONTEXT` only when blocked.** Genuine blockers: PRD §2 missing (no class name → no scaffold), PRD §4 missing (no operations → nothing to generate), or a reserved-name / generic-noun collision on the native module symbol the user won't resolve. Non-blockers that should NOT STOP: ARCHITECTURE §3.<n> missing fine implementation detail (use a reasonable default + annotate inline), ARCHITECTURE §8 missing UX for a specific code (use default mapping), framework choice in ARCHITECTURE §1.2 / §1.3 missing a deployment-target detail (use the framework's documented minimum).
- **Smoke check is a real gate.** If the native symbol-agreement check (Step 4) fails, `.extension-state.md` Phase stays below `scaffold` with `Status: blocked`. (The optional `pnpm install` is non-fatal — deferring the devDeps seed doesn't block.)
- **Every function/method called from generated native code MUST be defined in the same file or imported.** This is enforced by Step 3.8 (self-review). When writing `this.createFoo()` in a Kotlin Activity, also write `private fun createFoo() { ... }` in the same file. When writing `[self setupBar]` in an Obj-C module, also write `- (void)setupBar { ... }`. The smoke check (Step 4) compiles nothing — native code errors surface in `/build-android-binary` / `/build-ios-binary`. The self-review catches the "called but never defined" class of bug at generation time, not later when an engineer runs the binary build.
- **Capture / modal-presentation operations MUST include a Done action.** Any operation whose pattern is one-shot OR two-way with a confirm-and-return UX (drawing capture, photo capture, signature, scanner with manual confirm, etc.) MUST emit a toolbar Done action that triggers result submission. Cancel alone is not sufficient — the user has no way to complete the operation. On Android: `R.menu.<lower>_capture_menu` MUST include `<item android:id="@+id/action_done">` and `onOptionsItemSelected` handles it. On iOS: the `UINavigationItem` MUST set `rightBarButtonItem` to a Done `UIBarButtonItem`. Non-negotiable.
- **Multi-mode operations (mutually exclusive UI modes — e.g. capture/playback, photo/video, edit/preview) MUST include active-state visual feedback.** On Android: use `MaterialButtonToggleGroup` with `app:singleSelection="true"` for the mode toggle row; toggled-on button shows `colorPrimaryContainer`, toggled-off transparent. On iOS: rely on the platform's tool-picker (e.g. PencilKit, `AVCaptureSession`'s built-in UI) which handles this natively, OR use `UISegmentedControl` for custom toggles. Without active-state feedback, the user can't tell which mode is selected.
Allowed in generated code (these are not defects):
- **`// TODO:` or `// Customize:` comments** for items that legitimately need engineer judgment — non-trivial UI details that ARCHITECTURE §3.<n> doesn't specify (button corner radius, animation curves, edge case handling that the PRD intentionally defers). Clear annotated TODOs beat fabricated defaults.
- **Reasonable defaults for unspecified detail.** If ARCHITECTURE §3.<n> says "Done button right side" but doesn't specify the button's tint color, use the platform default (e.g. `.systemBlue` on iOS, `?attr/colorAccent` on Android). Add a `// Customize: tint per design` comment if it's likely the engineer will want to change it.
- **Fallback error responses** when something at runtime doesn't match the contract. If a native method receives args it can't parse, resolving the promise with an error JSON (`{"status":"error","error":"INVALID_INPUT","message":"<the offending field / parse reason>"}`) via the `errorJson(code, message)` helper is the right answer — not a thrown exception. Always include the `message` — same for `INTERNAL_ERROR` catch-alls (carry the underlying reason).
Other operational rules:
- No reference-repo fetch at runtime — structural patterns are encoded in this SKILL.md + `shared/*.md`.
- No PCF generation (that's `/generate-pcf-companion`).
- No manifest, binary build, or bundle assembly (that's `/generate-ppmplugin` and its stages).
- No `src/` TS layer, no tests (there's nothing to type-check; structural verification is `/test-native-extension`).
- No remote, feed, or registry access — this track is self-contained and local-only.
## Runtime fallbacks the generated control SHOULD have
These are correctness-positive — make the control resilient against unexpected runtime conditions:
- **Construction must not throw (crash-at-launch guard).** The module is built eagerly at bridge startup on a possibly Looper-less thread ([`ppmplugin-format §5`](../../shared/ppmplugin-format.md)); an uncaught throw in the constructor / `init{}` (Android) or `init` (iOS) takes down the whole host before any UI. Defer listener/hardware registration to first method call; pass an explicit `Handler(Looper.getMainLooper())` (never `null`); wrap unavoidable init in try/catch. See §3.4 (iOS) / §3.5 (Android).
- **Runtime permission + `currentActivity` guards** (Android): check a dangerous permission before the API call and resolve `PERMISSION_DENIED` on denial; guard `currentActivity != null` before presenting UI and resolve `NO_ACTIVITY` on null — never let `SecurityException` / NPE crash the host. See §3.5.
- **Native methods try/catch around argument parsing** → resolve with `{status: 'error', error: 'INVALID_INPUT', message: '<offending field / parse reason>'}` instead of throwing. The wrap bridge expects a resolved promise (an error JSON), not a rejected/thrown one.
- **Native error responses go through the `errorJson(code, message)` helper** (code + human-readable reason), not as thrown exceptions. The native side resolves the Promise with an error JSON string carrying both fields; the wrap bridge delivers it to the PCF verbatim (which surfaces `message` as its `ErrorMessage` output).
- **Native main-thread guarding for UI presentation** (`dispatch_async(dispatch_get_main_queue(), ...)` on iOS, `Handler(Looper.getMainLooper()).post { ... }` on Android). Presenting UI off main thread crashes; the dispatch is required.
- **Unknown error codes from native** that the PCF didn't anticipate → pass through to the response verbatim. Don't drop or remap; let the maker's Power Fx formula see the actual code.
These are runtime safety nets. They're allowed/encouraged regardless of other style decisions.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!