Plaud Embedded Capacitor Wrapper
iOS has no Web Bluetooth, and mobile browsers that do expose it can't reach Plaud's
proprietary handshake and audio codec — so a browser-based web app can't drive Plaud
recording hardware directly. This skill wraps the web app in a Capacitor native shell: a
thin iOS and/or Android app that loads the web app's URL in a native web view (WKWebView /
WebView) and exposes Plaud's precompiled native SDK to the web layer through a custom
Capacitor plugin (PlaudSdk). The web app calls PlaudSdk.* in JavaScript; Capacitor
serializes those calls across the bridge to native Swift or Java, which drives the platform
Bluetooth stack and the Plaud SDK.
One JS surface, two native implementations. The two plugins expose the same method names
and emit the same events, so the web app has no platform branching. Where the platforms
genuinely differ, the difference is documented on the type in references/plaud-sdk.ts
(see "Platform differences" below).
When to use this skill
Use it when the user wants to use a web app stack to create a mobile app (iOS and/or Android) that can integrate with Plaud devices via Plaud Embedded
If the user hasn't set up Plaud Embedded credentials or auth yet, they need the
plaud-embedded-project-setup-skill first. For pure audio transcription (no wrapper), see
plaud-embedded-transcription-api-skill.
Prerequisites
- Plaud Embedded credentials and a way to mint a per-user token (see the setup skill)
- For iOS: macOS with Xcode and a physical iPhone (the frameworks are arm64 device-only, and the Simulator has no Bluetooth radio)
- For Android: Android Studio and a physical Android phone (the emulator has no Bluetooth radio, so scanning finds nothing there)
Setup — three steps
Follow these in order. Each step has a detailed reference file; read the reference before running the commands.
Step 0 — If the Plaud-AI/embedded-capacitor repo is not cloned
Clone Plaud-AI/embedded-capacitor if not done so. The native plugins for both platforms, plus a sample Next.js app, live in this repo.
git clone https://github.com/Plaud-AI/embedded-capacitor.git
The pieces you copy out of it:
| Platform | Copy from | Into |
|---|---|---|
| iOS | ios/PlaudPlugin/ and ios/App/App/MainViewController.swift |
your ios/ |
| Android | android/app/libs/plaud-sdk.aar and android/app/src/main/java/ai/plaud/pwademo/PlaudSdkPlugin.java |
your android/ |
| Both | nextjs-demo/lib/plaud-sdk.ts (same file as references/plaud-sdk.ts) |
your web app's lib/ |
Step 1 — Install and initialize Capacitor
Install only the platform packages the user actually needs.
npm i @capacitor/core @capacitor/ios @capacitor/android @capacitor-community/bluetooth-le
npm i -D @capacitor/cli
npx cap init # sets appId / appName / webDir
npx cap add ios # scaffolds the ios/ project
npx cap add android # scaffolds the android/ project
npx cap sync ios
npx cap sync android
Then point the shell at the deployed web app by setting server.url in the root
capacitor.config.ts — one file, shared by both platforms. npx cap sync regenerates
ios/App/App/capacitor.config.json and android/app/src/main/assets/capacitor.config.json
from it, so editing either JSON directly gets overwritten on the next sync.
Step 2a — iOS: add the PlaudPlugin and wire it up
The plugin is a local SwiftPM package that bundles the three precompiled Plaud xcframeworks. Because it isn't an npm-installed plugin, Capacitor won't auto-register it — you register it by hand.
Copy
ios/PlaudPlugin/into your project'sios/directory.Copy
ios/App/App/MainViewController.swiftintoios/App/App/— it registers the plugin instance incapacitorDidLoad()(a runtime step).Link
PlaudPlugininto the App target in Xcode (a separate build step — copying the folder alone won't compile).npx cap open ios, then File → Add Package Dependencies → Add Local, selectios/PlaudPlugin, and add its library product to the App target the wayCapApp-SPMalready is. Without this,import PlaudPluginfails with "No such module 'PlaudPlugin'"; without step 2 it compiles but throws "PlaudSdk plugin is not implemented on iOS" at runtime. You need both.- Ensure the App target's minimum deployment is iOS 15.0+ — the Plaud xcframeworks
require it (
Package.swiftdeclares.iOS(.v15)).
- Ensure the App target's minimum deployment is iOS 15.0+ — the Plaud xcframeworks
require it (
Declare the Bluetooth entitlement in
ios/App/App/Info.plist— without it iOS kills the app the moment it touches CoreBluetooth:<key>NSBluetoothAlwaysUsageDescription</key> <string>Uses Bluetooth to connect and interact with peripheral BLE devices.</string> <key>UIBackgroundModes</key> <array> <string>bluetooth-central</string> </array>
Step 2b — Android: add the PlaudSdkPlugin and wire it up
The Android SDK ships as a bare .aar dropped into the app module, and the plugin class is
app-local, so — as on iOS — Capacitor won't auto-register it.
Copy
android/app/libs/plaud-sdk.aarinto yourandroid/app/libs/.Copy
android/app/src/main/java/ai/plaud/pwademo/PlaudSdkPlugin.javainto your app's package directory and change itspackagedeclaration to match yourapplicationId.Register it in
MainActivity, beforesuper.onCreate()(the analogue of iOS'scapacitorDidLoad()); without this it surfaces as "PlaudSdk plugin is not implemented on android":public class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(PlaudSdkPlugin.class); super.onCreate(savedInstanceState); } }Declare the
.aar's transitive dependencies inandroid/app/build.gradleand their versions inandroid/variables.gradle. This is the step that is easy to miss: a bare.aarcarries no POM, so nothing comes along automatically and the app dies at runtime withNoClassDefFoundErrorthe first time a missing code path runs. The full dependency block is in the reference. Note also that the stock template'sfileTree(include: ['*.jar'], dir: 'libs')must become['*.jar', '*.aar']or the.aaris never picked up at all.
No AndroidManifest edits are needed for Bluetooth. plaud-sdk.aar ships its own manifest
declaring BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE/COARSE_LOCATION and the WiFi
permissions, and the Gradle manifest merger folds them into the app. The runtime permission
prompt is handled inside startScan() by the plugin itself.
Full details for both platforms — Package.swift, the manual-registration mechanics on each
side, capacitor.config.json fields, the complete Gradle dependency block, and why each
piece exists — are in
references/capacitor-plugin-setup.md.
Step 3 — Call the SDK from your web app
Copy references/plaud-sdk.ts into your web app's lib/. It's
a typed wrapper around registerPlugin<PlaudSdkPlugin>("PlaudSdk") plus two CORS-workaround
helpers (readExportedFile, putBinaryNative). Then import and use it:
import { Capacitor, type PluginListenerHandle } from "@capacitor/core";
import { PlaudSdk, readExportedFile, type PlaudScanDevice, type PlaudFile } from "@/lib/plaud-sdk";
// Native code only exists inside the Capacitor shell — guard every call:
if (Capacitor.isNativePlatform()) {
await PlaudSdk.initSDK({ userAccessToken: token, customDomain: "platform-us.plaud.ai", userId });
await PlaudSdk.startScan();
}
This code is identical on iOS and Android — no Capacitor.getPlatform() branching. Even
Android's runtime Bluetooth permissions are requested by startScan() itself, so the JS flow
is the same on both.
The complete JS API (methods + events), the scan → connect → export flow, and the non-obvious remote-origin / CORS constraints are in references/plaud-sdk.ts and references/usage-example.md.
Key things that trip people up
- Remote origin, not bundled assets.
server.url(set incapacitor.config.ts) points at the deployed web app. Web changes are only visible on device after you deploy them —npx cap syncdoesn't bundle your web code. Same on both platforms. - Config lives in
capacitor.config.ts. The nativecapacitor.config.jsonfiles (one per platform) are generated bynpx cap sync; edit the root.tsor your changes get overwritten. - Native changes need a native rebuild. Editing
PlaudSdkPlugin.swift/MainViewController.swiftrequiresnpx cap sync ios+ rebuild in Xcode; editingPlaudSdkPlugin.java/MainActivity.java/ Gradle files requiresnpx cap sync android- rebuild in Android Studio. A web deploy alone won't pick either up.
- Run on physical hardware. The iOS frameworks are arm64 device-only and won't link for the Simulator; the Android emulator has no Bluetooth radio, so a scan there finds nothing.
fetch()to file:// or S3 URLs fails on both platforms. Because the WebView loads a remote origin, browserfetchof exported files (convertFileSrc) orPUTto S3 presigned URLs is blocked by CORS. Use the native-bridge helpersreadExportedFile()/putBinaryNative().customDomainis domain-only —platform-us.plaud.ai, nohttps://prefix.- Recording is device-driven. There are no start/stop-record JS methods; recording is
triggered by the physical device and surfaced as
recordStart/recordStop/recordPause/recordResumeevents. Refresh the file list after a stop. - Android: the missing Gradle dependencies. By far the most common Android failure is a
runtime
NoClassDefFoundErrorfrom skipping step 2b.4, or an unresolvedPlaudSdkPluginbecausefileTreestill says['*.jar'].
Platform differences
The JS surface is the same, but a few values can't be made identical. All of these are
documented on the types in references/plaud-sdk.ts:
- Device identity. iOS reports a CoreBluetooth peripheral UUID; Android has no such
handle and reports the MAC address. Android emits the MAC as
uuidand as an extramacAddressfield, soconnectBleDevice({ uuid })works unchanged on both. Treatuuidas an opaque token. supportWiFi. Comes from the iOS scan record; Android's scan record has no such flag, so it staysfalsethere until a device is connected.penState. iOS delivers seven values, Android four —findMyToken,hasSndpKeyanddeviceAccessTokenare simply absent on Android (optional in the shared type) rather than faked as zeros.PlaudFile.duration. On Android it's derived from raw-opus frame arithmetic, so forisOggrecordings it reads slightly long (ogg page headers aren't subtracted).- Export directory. iOS writes to
Documents/PlaudExports, Android tofiles/PlaudExports. Either way, use theoutputPathreturned byexportAudio. - Android-only diagnostic events.
connectFail,connectStage,handshakeWaitSure,btStatusandscanFailexist only on Android — the Android SDK collapses every failure intoconnectState(failed: true), so the plugin attaches a raw transport listener to recover the reason. They never fire on iOS; treat them as optional debugging detail.
Extending the plugin
To expose a native SDK feature that isn't on the JS surface yet, mirror the existing pattern end to end on both platforms, so the shared JS surface stays at parity:
- iOS — add a
CAPPluginMethodentry +@objc funcinios/PlaudPlugin/Sources/PlaudPlugin/PlaudSdkPlugin.swift, and forward SDK delegate callbacks vianotifyListeners. - Android — add an
@PluginMethod public voidinandroid/app/src/main/java/…/PlaudSdkPlugin.java, and forwardPlaudDeviceAgentListenercallbacks vianotifyListeners(the plugin'semit()helper hops to the main thread first). If the feature needs a new runtime permission, add it to the@CapacitorPlugin(permissions = …)annotation and request it in the method. - JS — add the matching method signature /
addListeneroverload and payload type inplaud-sdk.ts.
Both native plugins wrap the same facade — PlaudDeviceAgent and its listener callbacks —
so a feature that exists on one side almost always has a same-named counterpart on the other.
Read the two plugin sources side by side; each documents its platform-specific deviations
inline at the call site.