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
| Tool | Version | Why |
|---|---|---|
eximia binary | 0.2+ | This guide |
| JDK | 17+ | Android build |
| Android SDK | API 34 (target), API 24 (min) | Android build |
| Xcode | 15+ | iOS build (macOS only) |
| Apple Developer account | active | iOS signing (optional for dev) |
| An editor | any | recommended: 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
-
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. -
Native code — Kotlin for Android, Swift for iOS. Each plugin implements one interface (
SLMEximiaPlugin) with one function (execute). Awhen/switchon the action string dispatches to your logic. You return results via a callback (orasync throwson iOS). -
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 fourpermissionsto the generatedAndroidManifest.xml. - Add
androidx.core:core-ktx:1.13.0to the generatedbuild.gradle.kts. - Add the
NSLocationWhenInUseUsageDescriptionkey to the generatedInfo.plist. - Link
CoreLocationandNetworkExtensionframeworks 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 cansuspend-style anything inside. - Dispatch on
action— your JS callsSLMWifiInfo.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.contextis 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 -> Anysignature: return the result, throw to reject. The runtime converts throws to JS-sideSLMEximiaError.@objc(SLMWifiInfo)matches themainClassinplugin.jsonso 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. Runeximia plugins listto 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@JavascriptInterfaceon the WebView. - iOS:
window.webkit.messageHandlers.eximia.postMessage(json)— aWKScriptMessageHandler.
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
| Key | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Globally unique, kebab-case |
name | string | yes | PascalCase. The bridge name (SLMEximia.exec("$name", ...)) and globalAs default |
version | string | yes | SemVer |
description | string | no | Free text |
platforms | array | yes | Subset of ["android", "ios"] |
js | object | yes | See below |
android | object | conditional | Required when platforms includes "android" |
ios | object | conditional | Required when platforms includes "ios" |
hooks | array | no | Build-time hooks (rarely needed) |
js
| Key | Type | Required | Notes |
|---|---|---|---|
entry | string | yes | Path to the JS file, relative to plugin root |
globalAs | string | no | Window global to attach to. Defaults to name |
types | string | no | Path to .d.ts. The CLI copies it into the project |
android
| Key | Type | Required | Notes |
|---|---|---|---|
package | string | yes | Reverse-DNS package; matches your Kotlin package declaration |
mainClass | string | yes | FQN of your SLMEximiaPlugin subclass |
sources | array | yes | Glob patterns relative to plugin root |
minSdk | integer | no | Plugin's minimum API. The host app raises its minSdk to match the highest among its plugins |
permissions | array | no | <uses-permission> strings injected into AndroidManifest |
manifest | string | no | Path to an extra fragment XML to merge into AndroidManifest |
manifestApplication | string | no | XML fragment(s) injected inside the host's <application> |
dependencies | array | no | Gradle dep strings (group:artifact:version) |
assets | array | no | Files copied verbatim into APK assets/ |
fileProvider | object | no | Declares a FileProvider; CLI auto-generates the <provider> entry |
ios
| Key | Type | Required | Notes |
|---|---|---|---|
mainClass | string | yes | Swift class name (matches @objc(...) if used) |
sources | array | yes | Glob patterns |
minDeploymentTarget | string | no | e.g. "15.0". App's deployment target rises to match |
infoPlist | object | no | Keys merged into Info.plist |
frameworks | array | no | System frameworks to link (CoreLocation, AVFoundation, ...) |
resources | array | no | Files embedded in the plugin's SwiftPM target |
swiftPackageDependencies | array | no | External SwiftPM packages with URL + version |
cocoaPodsDependencies | array | no | CocoaPods (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 declare | Eximia injects it into | Notes |
|---|---|---|
android.permissions | <uses-permission> in AndroidManifest.xml | De-duped across plugins |
android.dependencies | dependencies { implementation(...) } in build.gradle.kts | Conflicting versions across plugins fail the build with a clear message |
android.manifestApplication | Inside <application> of AndroidManifest.xml | XML fragment validated |
android.fileProvider | <provider> entry + res/xml/file_paths.xml | Multiple plugins merge into one provider with authority ${applicationId}.fileprovider |
android.assets | app/src/main/assets/ of the APK | Filename collisions fail the build |
ios.infoPlist | Info.plist of the iOS target | Project-level ios.infoPlist in eximia.json wins over per-plugin |
ios.frameworks | Linker flags on the iOS target | Standard system frameworks |
ios.swiftPackageDependencies | Package.swift + project.yml (XcodeGen) | Same package across plugins must agree on version |
ios.resources | Plugin'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
/docs/bridge-contract— the wire-format spec in JSON Schema, with envelope examples./docs/plugin-manifest— the manifest reference, with three worked examples./docs/lifecycle— full lifecycle hook reference./docs/security— the security model and what theslm-eximia://scheme handler will and won't serve./docs/error-model— error code conventions and how to surface meaningful messages to JS.
Stuck? Open an issue at the repo or email dev@slm.cloud.