agentleFS
buildpad-aibuildpad-ai/skills/add-mobile-app/SKILL.md

add-mobile-app

Skill1 starsChanged 27 days agoMITView on GitHub

Wrap an existing Buildpad/Next.js app into real iOS and Android apps using Capacitor in server-URL mode — no rewrite, the native shell loads the live deployed web app so middleware, API routes, and cookie auth all keep working unchanged. Covers the official setup steps, the native-only adaptations this platform actually needs (OAuth/SSO redirect handling, safe-area insets, local dev-server testing, adding plugins such as local notifications), and the cross-platform gotchas found by hands-on testing (iOS's allowNavigation wildcard unreliability, the window.open vs real-click navigation-type bug, the Android emulator private-DNS hang, federated-IdP hosts, Android cookie persistence, JDK/Gradle versions). Use when the user says add-mobile-app, Capacitor, wrap for mobile, iOS/Android app from the web app, or wants to test a BuildPad app on an emulator/simulator.

  • Installs packages

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  • could be automatic: a setting could enforce it
  1. Very specific:

    Add libraries with npx expo install <pkg>, never npm install <pkg>.

    line 62
  2. Very specific:

    Run npm run typecheck, npx expo lint, npx expo-doctor before calling it done.

    line 70
  3. Very specific:

    Use assets/lib/apps/launch-url.ts, which clicks a real, attached <a> element.

    line 130
  4. Very specific:

    Add that exact host, npx cap sync, rebuild.

    line 184
  5. Distinctive:

    No web-code change is needed — this is not specific to SSO.

    line 196
  6. Very specific:

    After any config change: npx cap sync (copies config + plugins to both native projects), then rebuild natively — see Testing below.

    line 345

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Add Mobile App (Capacitor, Server-URL Mode)

Why server-URL mode, not a static export

A BuildPad app's auth, DaaS API proxying, and most of its logic live in Next.js middleware and server-rendered API routes. A statically exported Capacitor build can't run any of that — there's no server behind it. Server-URL mode instead points the native shell at the live, already-deployed web app (server.url: 'https://your-app.example.com'). The native project becomes a thin WebView wrapper; the web codebase doesn't need to know it's running inside one. This is the only approach that reuses the app as-is.

This also means: most iteration is testing against a running server (production or a local dev server), not rebuilding the native app. Only native config changes (icons, permissions, allowNavigation) need an actual native rebuild.

When NOT to use this

  • The app can't reach the backend without its own middleware/API routes (static export won't work — don't try to force webDir-only mode for an app like this).
  • A genuinely native UI/performance bar is the actual requirement. This produces a WebView-based app, not a from-scratch native UI. See the tradeoffs in references/limitations.instructions.md before committing to this approach for that case.

Pick the path first: two ways to get a BuildPad mobile app

BuildPad has a platform-managed mobile build feature (buildpad-platform PR #24, merged 2026-10-19; released to selected users behind per-project feature flags, dev first, not general availability). Decide which of these the user wants before touching code:

Project mobile app (this skill)Mobile microapp
StackCapacitor WebView shell around the deployed Next.js web app (server-URL mode)Expo (React Native) + Expo Router with its own API (app/api/**/+api.ts, hosted on Amplify)
Use whenThe project already has a web app and should simply ship as an appA genuinely native UI is wanted, or a separate mobile product on the same project's DaaS
Feature flagmobile_appmobile_microapps
AndroidSigned .apk / .aab built by the platform (keystore in AWS Secrets Manager)Signed Android builds by the platform; starter downloadable from the platform
iOSUnsigned simulator .app zip (on-demand EC2 Mac, Sydney)Same
Permissionmanage_mobile (Manage Mobile Apps)manage_mobile

Both: one active build per scope, atomic build numbers, builds time out (Android 45 min, iOS 110 min), audit events are written. Out of scope: signed .ipa / TestFlight (needs Apple signing), and no iOS build had been run end to end at merge time. If a user asks "is iOS/App Store ready?", say no.

Release status check (when asked whether it is released): the feature is merged and deployed on dev (release/dev). Production needs the iOS builder AMI copied to the prod account, the infra PR (xrad-cloudformation #9: state machines, Lambdas, builder stack) deployed there, the migrations run, and an explicit go. Until then it is enabled per project by flag. Verify current state in the platform repo (git log --grep=mobile; CHANGELOG had no mobile entry at merge time) and the "Mobile Apps" section of the Platform Guide rather than trusting this paragraph.

Mobile microapp rules (from the generated starter's AGENTS.md):

  • The starter ships on Expo SDK 55 at the time of writing. Expo changes every SDK: read the expo major in package.json and the matching https://docs.expo.dev/versions/v<major>.0.0/ docs; never answer from memory.
  • Add libraries with npx expo install <pkg>, never npm install <pkg>.
  • The app calls only its own API (apiJson in lib/api.ts). Secrets live in app/api/** and server/, never in the app and never in EXPO_PUBLIC_*.
  • android/ and ios/ are generated by expo prebuild: never create, edit or commit them; change native settings via app.json / config plugins.
  • The Android package in app.json is permanent once published. Never commit a keystore.
  • Pushing to main redeploys the API: work on a branch. MCP amplify_* tools need microappId, otherwise they change the project's main app.
  • Run npm run typecheck, npx expo lint, npx expo-doctor before calling it done.

Platform builds of a project app (what this skill must leave behind): the platform builds from the repo's main branch only (not local files or other branches). After running this skill, the Capacitor android/ project and capacitor.config.ts must be committed and pushed to main, or the repo check says "app not found". Flow on the project's Mobile page: repo check (errors block, warnings don't) → Enable Android builds (~1 min, generates the signing key once per app) → Build (optional version name, default 1.0.<build number>) → download APK (direct install / adb install) or AAB (Play Console). Project app build ≈ 3 min; microapp first build ≈ 15 min. Builds with no report-back fail after 45 min (Android) / ~110 min (iOS); setup fails after 15 min. Download links live a few minutes; built files expire, so rebuild. Disabling removes the build environment and files but keeps the signing key.

  • Website changes to a Capacitor app need no rebuild (the shell loads the live site); rebuild only for native changes (icons, permissions, plugins, allowNavigation).
  • Common build failure causes: committed android/ changes for a microapp, npm install instead of npx expo install, app.json errors.
  • Mobile microapp is the docs' recommended type; the project app is the Capacitor shell.
  • iOS: platform builds an unsigned simulator .app (.zip) on an AWS Mac (first build of the day ≈ 25 min, then ≈ 5 min; AWS can have no Mac capacity → waits/fails, retry). Needs Android builds enabled once on the same page. Install with xcrun simctl install booted App.app / xcrun simctl launch booted <bundle id> (bundle id = ios.bundleIdentifier in app.json). Real iPhone / TestFlight / App Store need Apple signing, which the platform does not do; day-to-day iOS work should use the user's own Mac (npx expo run:ios).
  • Docs: https://app.buildpad.ai/docs/mobile-apps/ (getting-started, android-builds, ios).

The rest of this skill (Critical Rules, setup, testing) is the Capacitor project-app path. Mobile microapps follow the generated starter instead.

Critical Rules

  1. Install order is the official Capacitor flow — don't skip steps. @capacitor/core + @capacitor/cli → npx cap init <appName> <appId> --web-dir=<placeholder> → @capacitor/ios + @capacitor/android → npx cap add ios / npx cap add android. The placeholder webDir only needs a minimal index.html — it's never shown while server.url is reachable, but cap add requires the directory to exist.
  2. Set server.url to the real deployed app, not a placeholder. Nothing renders without it for an app built this way.
  3. allowNavigation needs exact hostnames — do not rely on wildcards or '*'. Capacitor's WebView only loads server.url's own host inside the app by default; any other host (an OAuth redirect to login.microsoftonline.com, an SSO handoff to another product) gets bounced to the system browser unless listed. On iOS a * stands for exactly one label: *.domain.com matches a.domain.com but not domain.com or a.b.domain.com (doesHost in Capacitor's CAPInstanceConfiguration.swift). In hands-on testing even '*' bounced out on iOS while the identical config kept navigation in-WebView on Android, although Capacitor 8's iOS source matches a bare '*'; if you see this, first check that npx cap sync copied the config into ios/App/App/capacitor.config.json and that the app was rebuilt. Either way, list every host explicitly. See references/troubleshooting.instructions.md for the device-log evidence.
  4. Open a launched link in the same WebView with a real anchor click, never window.open(...). In the native shell window.open(url, '_blank') asks for a new window, and Capacitor hands every new window to the system browser whatever allowNavigation says. A same-window navigation goes through the allowNavigation check: a listed host stays in the WebView (server redirects in an SSO chain included, Rule 10), any other host goes to the system browser. Use assets/lib/apps/launch-url.ts, which clicks a real, attached <a> element. In device testing on iOS a bare location.href = url was rejected for an allowed host where the anchor click was accepted; Capacitor 8's iOS source checks allowNavigation by host before the navigation type, so that cause is not confirmed, but the anchor click worked on both platforms. Do not reach for @capacitor/browser's in-app sheet as a "fix": it opens an isolated cookie jar, which silently breaks SSO/session continuity instead (see Critical Rule 5).
  5. An isolated in-app browser view does not share the main WebView's session cookies. If a destination needs the user's existing login session (an SSO handoff through this app's own IdP, for instance), it must stay in the same WebView the main app uses — not @capacitor/browser's sheet, not a new window/tab. Getting this wrong looks like "the user gets logged out again for no reason" when launching an integrated app.
  6. ios: { contentInset: 'automatic' }, not custom native frame code, for safe-area/status-bar spacing. Capacitor's WebView frame is actively managed by its own CAPBridgeViewController on every layout pass; a one-time Auto Layout constraint change, or repeated manual .frame reassignment, both fight that and can break rendering entirely (confirmed: a naive frame-override attempt produced a fully black screen after reinstall). The documented contentInset config option is the supported way to do this.
  7. Local dev-server testing needs a different host per platform — localhost only resolves inside the iOS Simulator (it shares the Mac's own network); the Android emulator needs 10.0.2.2 (its alias for the host machine) instead. If testing both platforms against one dev server at once, use the host machine's real LAN IP (ipconfig getifaddr en0) — reachable from both. This also needs cleartext: true in config AND, on Android, android:usesCleartextTraffic="true" on the <application> tag in AndroidManifest.xml — Capacitor's own config flag alone does not override Android's OS-level cleartext block (API 28+).
  8. A fresh Android emulator may hang on every HTTPS request with no error — not a bug in the app or this setup. Root cause found: the emulator's Private DNS was set to automatic/opportunistic (DNS-over-TLS), and that handshake was failing in-environment, blocking all HTTPS behind it (confirmed: navigating the same WebView to a totally unrelated site hung identically). Fix: adb shell settings put global private_dns_mode off. Check this first on any blank-white-screen report from a fresh AVD.
  9. If back-navigation is wanted after a launched app replaces the whole WebView (no browser chrome, nothing like a back button exists by default), either enable webView.allowsBackForwardNavigationGestures = true (iOS, one-line, a real edge-swipe gesture) or add a small native overlay header — see references/native-back-navigation.instructions.md for a minimal, tested implementation on both platforms. Any such header must check the navigated URL's host, not just its path, to tell "we're on our own app's page" apart from "we're on a launched app's own root page" (both can have path /).
  10. A federated SSO chain needs every host in allowNavigation, not just the first IdP. An IdP like XAppHub may redirect on to Microsoft Entra (login.microsoftonline.com), Google, Okta, etc. Any host missing from the list is handed to the system browser (on a fresh emulator that is Chrome's first-run screen), and the login never returns to the app. Find the missing host instead of guessing: adb logcat -d | grep "dat=https" shows the START ... act=android.intent.action.VIEW dat=https://<host>/... line. Add that exact host, npx cap sync, rebuild. (Chains seen so far: xapphub.ai → login.microsoftonline.com.)
  11. Android WebView loses a fresh login cookie if the app is killed soon after login — flush cookies natively. The WebView keeps cookies in memory and writes them to disk lazily; Capacitor's Android code never calls CookieManager.flush(), and the CapacitorCookies plugin only patches document.cookie, not Set-Cookie headers, so it does not help. Symptom: log in, swipe the app away, reopen → login page again, even though the session cookie is persistent (e.g. Supabase's 400-day sb-*-auth-token). Copy assets/android/MainActivity.java (flush on pause, plus every 2 s on a background thread while visible). Verify with the cookie-database recipe in references/troubleshooting.instructions.md. No web-code change is needed — this is not specific to SSO.
  12. Capacitor 8 needs JDK 21 with Gradle 8.14 — 17 and 25 both fail. JDK 17: error: invalid source release: 21. JDK 25 (what current Android Studio bundles as its JBR): Unsupported class file major version 69 at settings.gradle. Point JAVA_HOME at a JDK 21 (a portable Temurin 21 zip is enough, no install needed) for gradlew.
  13. pnpm projects: android/capacitor.settings.gradle hard-codes a node_modules/.pnpm/... path. Building a copy of android/ elsewhere (e.g. on Windows while the repo lives in WSL) fails with No matching variant of project :capacitor-android / "No variants exist" because that path does not exist there. In the copy, point it at ../node_modules/@capacitor/android/capacitor and copy node_modules/@capacitor/android next to it.
  14. Safe-area CSS must not rely on env(safe-area-inset-*) alone on Android. Android 15+ enforces edge-to-edge, and WebView < 140 reports env(safe-area-inset-*) as 0 — headers end up under the status bar, bottom buttons under the nav bar. Capacitor's SystemBars plugin (registered by default) injects --safe-area-inset-top|bottom|left|right as a fallback, so define the app's inset tokens as max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px)) (same for the other sides). Keep viewport-fit=cover in the page viewport. iOS uses ios.contentInset: 'automatic' (Rule 6) instead.
  15. A plugin that is compiled in but missing from capacitor.plugins.json fails silently. npx cap sync writes the list of registered plugin classes to android/app/src/main/assets/capacitor.plugins.json. If the APK is built with a stale copy (e.g. []), the plugin's native code is present but never registered, so every call rejects with "not implemented" — which a try/catch around the call hides completely. Symptoms: no permission prompt, no native log line for the plugin. Check with unzip -p app-debug.apk assets/capacitor.plugins.json before debugging anything else, and watch adb logcat | grep "Capacitor/Plugin": a working call logs To native (Capacitor plugin): ... pluginId: <Plugin>, methodName: ....
  16. Plugins work from the remote (server-URL) page. The native bridge is injected for the server.url origin, so the deployed web code can call plugins as usual: depend on @capacitor/core + the plugin in the web app, guard with Capacitor.isNativePlatform(), and import() the plugin lazily so it never loads in a plain browser. Adding a plugin needs both a web deploy (the JS) and a native rebuild (the Java/Kotlin).
  17. Local Notifications on Android: opt out of exact alarms for immediate notifications. The plugin defaults to isExactNotification: true and, when "Alarms & reminders" isn't allowed (the Android 14+ default), it navigates the user to that settings page before scheduling. For a notification shown right away (no schedule.at) pass isExactNotification: false. Also note POST_NOTIFICATIONS is a runtime prompt on Android 13+: the first call asks, and nothing is shown if the user declines.
  18. Push notifications (FCM) on Android — what the plugin does and doesn't do. @capacitor/push-notifications only receives; a server must send through Firebase. Needed: android/app/google-services.json (Gradle applies the google-services plugin only when that file exists — commit it, it isn't a secret) and, for sending, a Firebase service account key (keep it out of git; base64 it into a server env var). Create a High-importance channel at startup (PushNotifications.createChannel, importance 5) and send with android.notification.channel_id set to it, or the banner won't pop up. Android does not display an FCM message while the app is open: handle pushNotificationReceived and show it yourself (e.g. via Local Notifications). In the background/killed state the Firebase SDK shows it (record tag FCM-Notification:<id> in dumpsys notification).
  19. Don't build the FCM sender inside a sandboxed scheduler. Stored code (cron jobs, hooks) gets no secrets in its environment unless the operator names them, and cannot require or import a library. Keep the sender in the server app (sign the Google OAuth JWT with jose — no Firebase SDK needed) and let the scheduled job just POST to a secret-guarded route, through whatever outbound request its runtime allows.
  20. Testing push on an emulator: delivery can lag by minutes after the app has been idle in the background. Observed: instant right after backgrounding, but ~2m20s late after a 4-minute idle (FCM returned 200 both times; the standby bucket was ACTIVE). Don't conclude "broken" from a single quick check — poll dumpsys notification --noredact | grep -c 'pkg=<appId>' over a couple of minutes, and confirm on a real device. adb shell am force-stop puts the app in the stopped state, which blocks FCM entirely — use it only to restart, not to simulate "swiped away".
  21. Geofencing with @capgo/background-geolocation (arrive-at-a-place triggers). Pattern that works: setupGeofencing({url, headers, notifyOnEntry:true, notifyOnExit:false, backgroundLocation:true, requestPermissions:false}) then addGeofence({identifier, latitude, longitude, radius}). The plugin's native worker POSTs each transition to url even with the app closed — no WebView, no cookies — so authenticate with your own token in headers (a server-minted HMAC of {userId, exp}, refreshed on every launch). Real payload keys: identifier, transition ("enter"|"exit"), enter, latitude, longitude, radius, payload. Delivery is best-effort (no retry/queue), so add a cooldown server-side. Gotchas: setupGeofencing requests permissions by default — pass requestPermissions:false for any silent/launch-time call and request only from a user action after an in-app disclosure; the plugin does not declare ACCESS_BACKGROUND_LOCATION — add it to the app manifest; on Android 11+ "Allow all the time" is not a dialog but the system Location permission settings page the app is sent to; geofence-only mode shows no persistent notification; registering while already inside the zone fires an immediate "enter" (initial trigger), so opening the app at the office can push once (the cooldown absorbs repeats).
  22. The Android emulator cannot verify movement-based geofence transitions. adb emu geo fix <lon> <lat> works and the initial-trigger path is fully testable (POST → 200 → push), but leaving and re-entering the zone produced no transition in every variant tried: teleport with the app killed, teleport with the app alive in the background, a 5-minute stay outside, and a continuous 1 Hz simulated walk. Play services' geofence engine needs motion signals the emulator doesn't provide. Confirm real entry/exit and reboot persistence (the plugin ships a GeofenceBootReceiver) on a physical device. Also: Play-services geofences are invisible to dumpsys location (the AOSP "Geofence Manager" section says "unregistered").
  23. Background location needs a Play Console declaration and an in-app disclosure. Explain before the first system prompt what the location is used for and that no history is kept. Store only the saved place, never a track.

Step-by-step setup

# 1. Core + CLI
pnpm add @capacitor/core
pnpm add -D @capacitor/cli

# 2. Minimal webDir placeholder (never actually shown in server-URL mode)
mkdir -p capacitor-www
cat > capacitor-www/index.html <<'EOF'
<!doctype html>
<html lang="en"><head><meta charset="utf-8" /></head>
<body><p>Loading…</p></body></html>
EOF

# 3. Init (writes capacitor.config.ts)
npx cap init "<AppName>" "<com.company.appname>" --web-dir="capacitor-www"

# 4. Platform packages + native projects
pnpm add @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android

Then edit capacitor.config.ts — this is the one required adaptation beyond the official guide, per Critical Rule 2:

const config: CapacitorConfig = {
  appId: 'com.company.appname',
  appName: 'AppName',
  webDir: 'capacitor-www',
  server: {
    url: 'https://your-deployed-app.example.com',
    // Exact hosts only — see Critical Rule 3.
    allowNavigation: ['login.microsoftonline.com', 'your-sso-partner.example.com'],
  },
  ios: { contentInset: 'automatic' }, // Critical Rule 6
};

After any config change: npx cap sync (copies config + plugins to both native projects), then rebuild natively — see Testing below.

Testing in the iOS Simulator and Android Emulator

# iOS — build + install + launch
xcodebuild -project ios/App/App.xcodeproj -scheme App \
  -destination 'platform=iOS Simulator,name=<device name>' \
  -configuration Debug -derivedDataPath /tmp/<app>-ios-build build
xcrun simctl install <device name> /tmp/<app>-ios-build/Build/Products/Debug-iphonesimulator/App.app
xcrun simctl launch <device name> <appId>

# Android — build + install (installDebug also installs)
cd android && ./gradlew installDebug

For local dev-server testing (JS changes, no native rebuild needed per reload — see Critical Rule 7):

# Get the LAN IP reachable from both platforms at once
ipconfig getifaddr en0
# Set server.url to http://<that IP>:3000, cleartext: true, npx cap sync, rebuild once.
# Then every `pnpm dev` change just needs an app relaunch, not a native rebuild.

On a fresh Android emulator, always check Critical Rule 8 first if the app shows a blank white screen that never loads. If it worked earlier and is blank now with the site reachable, cold-boot the emulator (see troubleshooting).

Windows emulator + WSL repo: WSL has no Android SDK/JDK, so build on the Windows side. Copy android/ plus node_modules/@capacitor/android to a Windows folder (Critical Rule 13), run gradlew.bat assembleDebug there with a JDK 21 (Critical Rule 12) and ANDROID_HOME set, then install with the SDK's adb.exe (adb.exe install -r <windows path>\app-debug.apk). After any npx cap sync (config change or a new plugin), re-copy the synced files into the build copy, not just the Gradle ones: all of android/app/src/main/assets/ (capacitor.config.json and capacitor.plugins.json), android/app/capacitor.build.gradle, and android/capacitor.settings.gradle (re-apply the .pnpm path fix from Rule 13 for every plugin), plus node_modules/@capacitor/<plugin> for each new plugin. Missing capacitor.plugins.json is the sneaky one — see Critical Rule 15.

Assets

  • assets/lib/native/platform.ts — the isNative() / platform() detection helper every other mobile-aware code path builds on.
  • assets/lib/apps/launch-url.ts — the real-anchor-click navigation helper (Critical Rule 4), with the web fallback preserved. It imports isNative() from ../native/platform, so copy both files, keeping lib/apps/ and lib/native/ side by side.
  • assets/android/MainActivity.java — cookie-flushing activity (Critical Rule 11); replaces the generated android/app/src/main/java/<appId path>/MainActivity.java (fix the package line to your appId).

References

  • references/troubleshooting.instructions.md — the device-log evidence behind Critical Rules 3, 4, and 8, for when something in this setup needs re-diagnosing rather than just re-applying the fix.
  • references/native-back-navigation.instructions.md — tested native header-bar implementation for both platforms (Critical Rule 9).
  • references/limitations.instructions.md — what this approach genuinely does not give you, for scoping the decision to use it at all.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.