Eximia Download

Eximia docs

Authoring an Eximia plugin

A complete guide to writing your first SLM Eximia plugin from scratch. This is the value-add of the public Eximia release: the framework ships with one example plugin (device-info); everything else you need, you build.

Audience

You're a mobile developer who wants to call native Android (Kotlin) and iOS (Swift) APIs from a HTML/CSS/JS app built with Eximia. You don't have to be a Kotlin or Swift expert — the bridge is intentionally small and the patterns are repetitive. If you can write an Android Activity extension or a Swift func, you can write an Eximia plugin.

Prerequisites

ToolVersionWhy
eximia binary0.2+This guide
JDK17+Android build
Android SDKAPI 34 (target), API 24 (min)Android build
Xcode15+iOS build (macOS only)
Apple Developer accountactiveiOS signing (optional for dev)
An editoranyrecommended: IntelliJ / Android Studio for Kotlin, Xcode for Swift

You do NOT need: Node, npm, Cordova, Rust, the Eximia monorepo, or any access to SLM internals. The binary carries everything.


1 · What a plugin is

A plugin is a bridge between JavaScript and native OS APIs. Your HTML/CSS/JS app can't directly touch the camera, the biometric sensor, the NFC stack, or push notifications. A plugin closes that gap: native code (Kotlin/Swift) calls the OS API, and a JS proxy exposes the result to your app as a typed Promise.

A plugin is a directory with this structure:

my-plugin/
├── plugin.json          ← (1) MANIFEST    — declares what's needed
├── README.md
├── src/
│   ├── android/
│   │   └── MyPlugin.kt  ← (2) NATIVE      — Kotlin (Android)
│   └── ios/
│       └── MyPlugin.swift                 — Swift (iOS)
└── www/
    └── index.js         ← (3) JS API      — what your app imports

Three layers and a manifest. Same shape across every plugin — the one that ships in the binary (device-info), the ones you write, and the ones you'll see in any third-party plugin in the future.

The 3 layers explained

  1. Manifest (plugin.json) — JSON description of the plugin: id, version, target platforms, source files, permissions, dependencies, Info.plist keys, Gradle deps. The Eximia binary reads this at build time and auto-injects everything declared here into the generated Android/iOS project.

  2. Native code — Kotlin for Android, Swift for iOS. Each plugin implements one interface (SLMEximiaPlugin) with one function (execute). A when/switch on the action string dispatches to your logic. You return results via a callback (or async throws on iOS).

  3. JS API — a thin wrapper over SLMEximia.exec(plugin, action, args) that exposes typed functions. This is what app developers import and call.


2 · The example: a wifi-info plugin

We'll build a complete wifi-info plugin that scans nearby Wi-Fi networks and returns {ssid, bssid, rssi, secured} for each. It's a realistic example: needs permissions on both platforms, links system frameworks, and shows the full bridge round-trip.

2.1 Scaffold

$ eximia plugin init wifi-info
  ✓ created ./plugins/wifi-info/plugin.json
  ✓ created ./plugins/wifi-info/README.md
  ✓ created ./plugins/wifi-info/src/android/SLMWifiInfo.kt
  ✓ created ./plugins/wifi-info/src/ios/SLMWifiInfo.swift
  ✓ created ./plugins/wifi-info/www/index.js

Next: edit plugin.json and the source files, then add to eximia.json.

The scaffold gives you a working "echo plugin" you can iterate on.

2.2 Edit plugin.json

This is where you tell Eximia what your plugin needs. Declare it once; the binary injects it everywhere it's needed at build time.

{
  "$schema": "https://eximia.slmeximia.site/schemas/plugin.schema.json",

  "id":      "wifi-info",
  "name":    "SLMWifiInfo",
  "version": "0.1.0",
  "description": "Scan nearby Wi-Fi networks (SSID, BSSID, RSSI, security).",

  "platforms": ["android", "ios"],

  "js": {
    "entry":   "www/index.js",
    "globalAs": "SLMWifiInfo"
  },

  "android": {
    "package":   "com.example.wifi_info",
    "mainClass": "com.example.wifi_info.SLMWifiInfo",
    "sources":   ["src/android/**/*.kt"],
    "minSdk":    24,

    "permissions": [
      "android.permission.ACCESS_WIFI_STATE",
      "android.permission.CHANGE_WIFI_STATE",
      "android.permission.ACCESS_FINE_LOCATION"
    ],
    "dependencies": [
      "androidx.core:core-ktx:1.13.0"
    ]
  },

  "ios": {
    "mainClass":  "SLMWifiInfo",
    "sources":    ["src/ios/**/*.swift"],
    "minDeploymentTarget": "15.0",
    "frameworks": ["CoreLocation", "NetworkExtension"],
    "infoPlist": {
      "NSLocationWhenInUseUsageDescription":
        "Needed to scan nearby Wi-Fi networks."
    }
  }
}

The Eximia binary, at build time, will:

  • Add <uses-permission> entries for each of the four permissions to the generated AndroidManifest.xml.
  • Add androidx.core:core-ktx:1.13.0 to the generated build.gradle.kts.
  • Add the NSLocationWhenInUseUsageDescription key to the generated Info.plist.
  • Link CoreLocation and NetworkExtension frameworks on the iOS target.

You never touch any of those files manually.

2.3 Write the Android side

src/android/SLMWifiInfo.kt:

package com.example.wifi_info

import android.net.wifi.WifiManager
import com.slm.eximia.SLMEximiaPlugin
import com.slm.eximia.SLMEximiaCallback
import org.json.JSONArray
import org.json.JSONObject

class SLMWifiInfo : SLMEximiaPlugin() {

    override suspend fun execute(
        action: String,
        args:   JSONObject,
        cb:     SLMEximiaCallback,
    ) {
        when (action) {
            "scan" -> handleScan(cb)
            else   -> cb.error(
                "wifi-info/unknown-action",
                "Unknown action: $action"
            )
        }
    }

    private suspend fun handleScan(cb: SLMEximiaCallback) {
        // Permissions get auto-requested before execute() runs when
        // declared in plugin.json. If you need on-demand ones, use
        // requestPermission(...) inherited from SLMEximiaPlugin.
        val wifi = context.getSystemService(WifiManager::class.java)

        val nets = JSONArray()
        for (result in wifi.scanResults) {
            nets.put(JSONObject().apply {
                put("ssid",    result.SSID)
                put("bssid",   result.BSSID)
                put("rssi",    result.level)
                put("secured", result.capabilities.contains("WPA"))
            })
        }
        cb.success(JSONObject().put("networks", nets))
    }
}

Key points:

  • Extend SLMEximiaPlugin — the abstract base from the runtime.
  • Override one execute() function. The runtime calls it on a coroutine scope; you can suspend-style anything inside.
  • Dispatch on action — your JS calls SLMWifiInfo.scan() which goes through the bridge as {action: "scan", args: {}, ...}.
  • cb.success(JSONObject) sends a successful result back to JS; cb.error(code, message) rejects the JS Promise.
  • context is the Android context, inherited from the base class.

2.4 Write the iOS side

src/ios/SLMWifiInfo.swift:

import CoreLocation
import NetworkExtension
import SLMEximia

@objc(SLMWifiInfo) final class SLMWifiInfo: SLMEximiaPlugin {

    override func execute(
        action: String,
        args:   [String: Any]
    ) async throws -> Any {
        switch action {
        case "scan":
            return try await handleScan()
        default:
            throw SLMEximiaError.unknownAction(action)
        }
    }

    private func handleScan() async throws -> [String: Any] {
        let nets = try await NEHotspotNetwork.fetchCurrent()
        let mapped: [[String: Any]] = nets.map { net in
            [
                "ssid":    net.ssid,
                "bssid":   net.bssid,
                "rssi":    net.signalStrength,
                "secured": net.isSecure,
            ]
        }
        return ["networks": mapped]
    }
}

Key points:

  • Inherits from SLMEximiaPlugin (the Swift base class).
  • async throws -> Any signature: return the result, throw to reject. The runtime converts throws to JS-side SLMEximiaError.
  • @objc(SLMWifiInfo) matches the mainClass in plugin.json so the bridge can resolve the symbol at runtime.

2.5 Write the JS API

www/index.js:

const PLUGIN_NAME = "SLMWifiInfo";

const SLMWifiInfo = {
  /**
   * Scan nearby Wi-Fi networks.
   * @returns {Promise<{ networks: { ssid: string, bssid: string,
   *                                  rssi: number, secured: boolean }[] }>}
   */
  scan() {
    return SLMEximia.exec(PLUGIN_NAME, "scan", {});
  },
};

if (typeof module !== "undefined") module.exports = SLMWifiInfo;
if (typeof window !== "undefined") window.SLMWifiInfo = SLMWifiInfo;

For TypeScript users, ship a www/index.d.ts next to it:

export interface WiFiNetwork {
  ssid: string;
  bssid: string;
  rssi: number;
  secured: boolean;
}
export interface SLMWifiInfo {
  scan(): Promise<{ networks: WiFiNetwork[] }>;
}
declare const _default: SLMWifiInfo;
export default _default;
declare global {
  interface Window { SLMWifiInfo: SLMWifiInfo }
}

Then update plugin.json#js:

"js": {
  "entry":    "www/index.js",
  "globalAs": "SLMWifiInfo",
  "types":    "www/index.d.ts"
}

2.6 Validate

Before adding the plugin to a project, validate the structure:

$ eximia plugin validate ./plugins/wifi-info
  ✓ plugin.json matches schema
  ✓ android.mainClass exists in src/android/
  ✓ ios.mainClass exists in src/ios/
  ✓ js.entry exists in www/
  ✓ all manifest cross-references resolve

2.7 Use it in a project

In your app's eximia.json:

{
  "appId":   "com.example.myapp",
  "version": "1.0.0",
  "platforms": ["android", "ios"],
  "app": { "dir": "./www" },

  "plugins": [
    "device-info",                  // ← bundled with the binary
    "./plugins/wifi-info"           // ← your custom plugin, by path
  ]
}

The Eximia binary distinguishes the two automatically:

  • Short names ("device-info") resolve to plugins bundled in the binary. Run eximia plugins list to see what's available.
  • Paths ("./plugins/wifi-info") resolve to plugins on your filesystem.

In your www/app.js:

const { networks } = await SLMWifiInfo.scan();
console.log(`Found ${networks.length} networks`);
console.log(`Strongest: ${networks[0].ssid}`);

Build:

$ eximia build --spec eximia.json --out ./build/
  ✓ resolved 2 plugins (device-info, wifi-info)
  ✓ staged HTML/CSS/JS app from ./www
  ✓ injected permissions: ACCESS_WIFI_STATE, ACCESS_FINE_LOCATION, ...
  ✓ injected gradle deps: androidx.core:core-ktx:1.13.0
  ✓ injected Info.plist keys: NSLocationWhenInUseUsageDescription
  ✓ assembled and signed app.apk     (45s)
  ✓ assembled and signed app.ipa     (1m12s)

Done. Your plugin is now in both APK and IPA.


3 · The bridge protocol

Every plugin call follows the same wire format. Useful to know when debugging, less so for day-to-day use.

3.1 JS → Native (request)

SLMEximia.exec("SLMWifiInfo", "scan", { secured: true });

becomes, on the wire:

{
  "plugin":   "SLMWifiInfo",
  "action":   "scan",
  "args":     { "secured": true },
  "callbackId": "cb_42"
}

dispatched via:

  • Android: __eximia_bridge__.exec(jsonString) — a @JavascriptInterface on the WebView.
  • iOS: window.webkit.messageHandlers.eximia.postMessage(json) — a WKScriptMessageHandler.

3.2 Native → JS (success)

The runtime calls back into the WebView:

SLMEximia.__resolve("cb_42", { networks: [...] });

which resolves the Promise the original exec() returned.

3.3 Native → JS (error)

SLMEximia.__reject("cb_42", {
  code:    "wifi-info/permission-denied",
  message: "Location permission was denied",
  details: {}
});

which rejects with SLMEximiaError:

try {
  await SLMWifiInfo.scan();
} catch (err) {
  if (err.code === "wifi-info/permission-denied") {
    // handle
  }
}

3.4 Native → JS (progress)

For long-running operations, send progress events without resolving:

// Android
cb.progress(JSONObject().apply {
    put("loaded", loaded)
    put("total",  total)
})

In JS:

SLMEximia.exec("SLMDownload", "fetch", { url }, (progress) => {
  console.log(`${progress.loaded}/${progress.total}`);
}).then((result) => {
  console.log("done", result);
});

4 · Plugin manifest reference

Every key supported by plugin.json, what it does, and where it ends up in your generated build.

Top-level

KeyTypeRequiredNotes
idstringyesGlobally unique, kebab-case
namestringyesPascalCase. The bridge name (SLMEximia.exec("$name", ...)) and globalAs default
versionstringyesSemVer
descriptionstringnoFree text
platformsarrayyesSubset of ["android", "ios"]
jsobjectyesSee below
androidobjectconditionalRequired when platforms includes "android"
iosobjectconditionalRequired when platforms includes "ios"
hooksarraynoBuild-time hooks (rarely needed)

js

KeyTypeRequiredNotes
entrystringyesPath to the JS file, relative to plugin root
globalAsstringnoWindow global to attach to. Defaults to name
typesstringnoPath to .d.ts. The CLI copies it into the project

android

KeyTypeRequiredNotes
packagestringyesReverse-DNS package; matches your Kotlin package declaration
mainClassstringyesFQN of your SLMEximiaPlugin subclass
sourcesarrayyesGlob patterns relative to plugin root
minSdkintegernoPlugin's minimum API. The host app raises its minSdk to match the highest among its plugins
permissionsarrayno<uses-permission> strings injected into AndroidManifest
manifeststringnoPath to an extra fragment XML to merge into AndroidManifest
manifestApplicationstringnoXML fragment(s) injected inside the host's <application>
dependenciesarraynoGradle dep strings (group:artifact:version)
assetsarraynoFiles copied verbatim into APK assets/
fileProviderobjectnoDeclares a FileProvider; CLI auto-generates the <provider> entry

ios

KeyTypeRequiredNotes
mainClassstringyesSwift class name (matches @objc(...) if used)
sourcesarrayyesGlob patterns
minDeploymentTargetstringnoe.g. "15.0". App's deployment target rises to match
infoPlistobjectnoKeys merged into Info.plist
frameworksarraynoSystem frameworks to link (CoreLocation, AVFoundation, ...)
resourcesarraynoFiles embedded in the plugin's SwiftPM target
swiftPackageDependenciesarraynoExternal SwiftPM packages with URL + version
cocoaPodsDependenciesarraynoCocoaPods (partial support; see status)

See schemas/plugin.schema.json for the authoritative source.


5 · Auto-injection — what you declare, where it ends up

Quick lookup table for the most common case: "where does the thing I wrote in plugin.json end up in my build?"

You declareEximia injects it intoNotes
android.permissions<uses-permission> in AndroidManifest.xmlDe-duped across plugins
android.dependenciesdependencies { implementation(...) } in build.gradle.ktsConflicting versions across plugins fail the build with a clear message
android.manifestApplicationInside <application> of AndroidManifest.xmlXML fragment validated
android.fileProvider<provider> entry + res/xml/file_paths.xmlMultiple plugins merge into one provider with authority ${applicationId}.fileprovider
android.assetsapp/src/main/assets/ of the APKFilename collisions fail the build
ios.infoPlistInfo.plist of the iOS targetProject-level ios.infoPlist in eximia.json wins over per-plugin
ios.frameworksLinker flags on the iOS targetStandard system frameworks
ios.swiftPackageDependenciesPackage.swift + project.yml (XcodeGen)Same package across plugins must agree on version
ios.resourcesPlugin's SwiftPM target resources (Bundle.module)Access at runtime via Bundle.module.url(forResource:)

The principle: declare it once in plugin.json, never edit generated files by hand. If you find yourself wanting to edit the Manifest or Info.plist directly, that's a sign the manifest needs a richer field — file an issue.


6 · Plugin lifecycle

Initialization

When the host app starts, the runtime walks the plugin registry and instantiates each plugin once. Plugins can override onLoad() to initialize state:

override fun onLoad() {
    super.onLoad()
    // Wire up listeners, init SDKs, etc.
}

Permissions

The cleanest pattern is to declare permissions in plugin.json and let the runtime auto-request them on first execute(). For on-demand requests (e.g. camera permission only when the user taps a specific button):

override suspend fun execute(action: String, args: JSONObject, cb: SLMEximiaCallback) {
    if (!hasPermission(android.Manifest.permission.CAMERA)) {
        val granted = requestPermission(android.Manifest.permission.CAMERA)
        if (!granted) {
            cb.error("camera/permission-denied", "User denied camera access")
            return
        }
    }
    // ... safe to use camera
}

Activity results (Android)

For plugins that launch external activities (gallery picker, camera intent, share sheet), override onActivityResult:

private var pendingCallback: SLMEximiaCallback? = null

override suspend fun execute(action: String, args: JSONObject, cb: SLMEximiaCallback) {
    when (action) {
        "pickImage" -> {
            pendingCallback = cb
            val intent = Intent(Intent.ACTION_PICK).apply {
                type = "image/*"
            }
            activity.startActivityForResult(intent, REQ_PICK)
        }
    }
}

override fun onActivityResult(req: Int, res: Int, data: Intent?) {
    if (req != REQ_PICK) return
    val cb = pendingCallback ?: return
    pendingCallback = null
    if (res != Activity.RESULT_OK) {
        cb.error("picker/cancelled", "User cancelled")
        return
    }
    val uri = data?.data ?: return cb.error("picker/no-data", "Missing URI")
    cb.success(JSONObject().put("uri", uri.toString()))
}

The runtime routes onActivityResult calls to plugins that opted in during onLoad() (call subscribeActivityResult(REQ_PICK)).

Foreground / background

Both runtimes dispatch onPause() and onResume() to plugins that need them — useful for plugins that hold open resources (camera, location).


7 · Testing

Both runtimes ship a FakeCallback test helper. Use it to assert your plugin's behaviour without spinning up a full Activity / View.

Android unit test

tests/android/SLMWifiInfoTest.kt:

import com.slm.eximia.test.FakeCallback
import com.slm.eximia.test.FakeContext
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals

class SLMWifiInfoTest {

    @Test
    fun returns_unknown_action_error() = runTest {
        val plugin = SLMWifiInfo().apply { attach(FakeContext()) }
        val cb = FakeCallback()
        plugin.execute("nope", JSONObject(), cb)
        assertEquals("wifi-info/unknown-action", cb.errorCode)
    }
}

iOS unit test

tests/ios/SLMWifiInfoTests.swift:

import XCTest
@testable import SLMWifiInfo
import SLMEximiaTest

final class SLMWifiInfoTests: XCTestCase {
    func testUnknownAction() async {
        let plugin = SLMWifiInfo()
        do {
            _ = try await plugin.execute(action: "nope", args: [:])
            XCTFail("expected throw")
        } catch let e as SLMEximiaError {
            XCTAssertEqual(e.code, "wifi-info/unknown-action")
        }
    }
}

Run with gradle test / swift test from each platform's root.


8 · Distribution

Today (v1.0)

Plugins are referenced by local path in the consuming project's eximia.json:

"plugins": [
  "device-info",                  // from the binary's bundle
  "./plugins/wifi-info",          // sibling directory
  "../shared-plugins/analytics"   // monorepo neighbor
]

You can keep your plugins in:

  • The same repository as the app (./plugins/...)
  • A shared monorepo (../shared/...)
  • A private vendored directory (./vendor/...)
  • A git submodule (resolves to a regular path)

Tomorrow (deferred to v1.1)

Future versions will accept git URLs and tarballs:

"plugins": [
  "git+https://github.com/acme/eximia-wifi-info@v1.0.0",
  "https://cdn.example.com/plugins/wifi-info-1.0.0.tgz"
]

Each entry will be content-addressed via pluginsLock for reproducibility (same hash → same plugin sources). Tracking issue: Phase 5b in ROADMAP.md.


9 · The device-info reference plugin

Eximia ships device-info as a working public example. It's the simplest possible plugin — no permissions, no native deps, just reads Build.MODEL, UIDevice.current.systemVersion, screen size, etc.

Find it on disk after extracting the binary's cache:

$ eximia plugins show device-info
name        : device-info
category    : system
version     : 0.1.0
platforms   : android, ios
description : Device, battery and network metadata. UDID/model/OS/...

Read its source to see the patterns in their simplest form. When you write your first non-trivial plugin, copy the directory and modify.


10 · Troubleshooting

"unknown plugin" at build time

Error: unknown plugin "wifi-infos"; available: device-info, ...

You misspelled the plugin name in eximia.json#plugins[], or the path doesn't resolve. Run eximia plugins list to see bundled names.

"permission denied" at runtime, even though I declared it

Android 13+ requires runtime permission requests for a growing set of permissions even when declared in the manifest. If hasPermission(...) returns false despite the declaration, request on-demand with requestPermission(...).

Conflicting Gradle dependency versions

Error: plugin 'wifi-info' wants androidx.core:core-ktx:1.13.0
       plugin 'biometric' wants androidx.core:core-ktx:1.10.0

Two plugins disagree on a transitive dep version. Resolve by picking one version in your project's eximia.json#android.gradle.deps (a project-level override; see project spec docs).

iOS build fails with "framework not found"

Make sure the framework name in plugin.json#ios.frameworks matches exactly (case-sensitive). System frameworks live under System/Library/Frameworks/; third-party ones come via swiftPackageDependencies.

My Kotlin plugin compiles but execute() is never called

Check plugin.json#android.mainClass matches the FQN of your class (package + class name). The CLI generates PluginsHost.kt from this string; a typo silently means your plugin isn't registered.

The bridge call hangs forever

Your plugin's execute() never called cb.success(...) or cb.error(...). Make sure every code path through your when/switch calls one of them before returning. Use a defer / finally block for safety if you have early returns.


Where to go next

Stuck? Open an issue at the repo or email dev@slm.cloud.