@independo/capacitor-inderun
v1.1.0
Published
On-device AI with cloud fallback for Capacitor apps. Apple Foundation Models on iOS, ML Kit GenAI (Gemini Nano) on Android, an OpenAI-compatible endpoint as fallback — one API, with streaming and cancellation.
Readme
@independo/capacitor-inderun
On-device AI with cloud fallback, for Capacitor apps.
Runs a prompt on the device when the device can — Apple Foundation Models on iOS, ML Kit GenAI (Gemini Nano) on Android — and falls back to an OpenAI-compatible cloud endpoint when it cannot. One API across iOS, Android and the web, with streaming and cancellation. MIT.
It is a thin bridge, not a second engine. The package delegates to the released IndeRun platform SDKs instead of re-implementing routing, provider logic, or normalized error handling:
- web:
@independo/inderun-web(npm) - iOS:
IndeRunSwift package (github.com/independo-gmbh/inderun, SwiftPM) - Android:
app.independo.inderun:inderun-*(Maven Central)
This repository is the SwiftPM/npm-publishable home of the Capacitor bridge only. The native SDKs it wraps live in the main
independo-gmbh/inderunmonorepo.
Install
pnpm add @independo/capacitor-inderun @capacitor/coreThen sync native projects with your normal Capacitor workflow.
Platform Requirements
| Platform | Minimum version | |----------|------------------------| | iOS | 16.0 | | Android | API 26 (Android 8.0) | | Web | Any modern browser |
The iOS floor comes from the IndeRun Swift package this bridge depends on, not from the bridge itself. On-device execution needs more than the floor: Apple Foundation Models requires an Apple Intelligence–capable device on iOS 26+, and ML Kit GenAI requires AICore / Gemini Nano support. Availability is checked at runtime and the cloud provider serves the request when it is missing.
Host Project Requirements
The IndeRun SDKs this bridge wraps are newer than a freshly generated Capacitor app's
defaults, so cap add alone is not enough. Each of these is a hard requirement — the
corresponding build failure is named so it is searchable:
Android (android/variables.gradle and android/build.gradle in your app):
| Setting | Value | Failure if unset |
|---|---|---|
| compileSdkVersion | 37 | "requires libraries and applications that depend on it to compile against version 37 or later" |
| minSdkVersion | 26 | manifest merger conflict |
| Android Gradle Plugin | 9.1.0+ (Gradle 9.x) | "requires Android Gradle plugin 9.1.0 or higher" |
| org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10 on the app's buildscript classpath | — | "Module was compiled with an incompatible version of Kotlin … metadata is 2.4.0, expected version is 2.2.0" |
The Kotlin one is the surprising entry: the inderun-* artifacts carry Kotlin 2.4.x
metadata, which the Kotlin plugin AGP brings by default cannot read. The plugin's own build
hoists a newer KGP for its standalone build, but a consuming app resolves KGP from its own
buildscript classpath, so the app has to add it too:
// android/build.gradle
buildscript {
dependencies {
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10'
}
}AGP 9 also rejects getDefaultProguardFile('proguard-android.txt'); switch to
proguard-android-optimize.txt.
iOS: set IPHONEOS_DEPLOYMENT_TARGET to 16.0 in your Xcode project before
npx cap sync ios. The Capacitor CLI reads that value to generate CapApp-SPM/Package.swift,
so syncing with the default leaves a manifest pinned below this package's floor and the build
fails with "requires minimum platform version 16.0 for the iOS platform, but this target
supports 15.0".
Supported IndeRun Version
This release tracks IndeRun 0.3.2 on all three platforms:
| Platform | Artifact | Constraint |
|----------|----------|------------|
| Web | @independo/inderun-web, @independo/inderun-contracts | 0.3.2 (exact) |
| iOS | inderun SwiftPM package | >=0.3.2 <0.4.0 |
| Android | app.independo.inderun:inderun-* | 0.3.2 (exact) |
The npm and Gradle pins are exact and all three are bumped together — a partial bump is how the
platforms drift apart. The SwiftPM constraint is ranged rather than exact so an app that also depends
on inderun directly can still unify its package graph; it stops at the next minor because that is
where an 0.x SDK's breaking changes live.
This package versions independently of the IndeRun monorepo under plain semver — the numbers are unrelated, and this bridge's own version says nothing about which IndeRun release it wraps. Read that off the table above.
Usage
import { createIndeRunCapacitor } from "@independo/capacitor-inderun";
const inderun = createIndeRunCapacitor({
openAI: {
model: "gpt-5.2",
endpointUrl: "/api/inderun/openai-responses",
auth: "none"
}
});
const result = await inderun.run({
schemaVersion: "1.0",
task: { kind: "text_to_text" },
prompt: "Summarize why a thin bridge matters.",
constraints: { privacy: "cloud_required" }
});Streaming (Mode 2)
stream() returns the same StreamRun shape the platform SDKs return directly: the
run's handle, its canonical event sequence, and a cancel hook.
const run = await inderun.stream({
schemaVersion: "1.0",
task: { kind: "text_to_text" },
prompt: "Explain routing in two sentences."
});
console.log(run.handle.runId); // available before the first event
let text = "";
for await (const event of run.events) {
switch (event.type) {
case "content_delta":
text += event.payload.text; // append
break;
case "content_snapshot":
text = event.payload.text ?? ""; // replace — an empty one retracts
break;
case "terminal":
// exactly one of "completed" | "error" | "cancelled"
console.log(event.payload.outcome);
break;
}
}
run.cancel("user navigated away"); // idempotent; a no-op after the terminalEvent semantics, ordering, the terminal guarantees, cancellation and fallback are the engines' contract, identical on every platform, and documented once in Streaming (Mode 2). Two things are specific to reaching them through a bridge:
- The bridge hop is not order-preserving. That is why
sequencerather than arrival is the contract's ordering authority.eventsalready yields strictly by it; if you attach your own listener toSTREAM_EVENT_NAMEinstead, ordering is yours to enforce. - A failed run is not a rejected promise, and a bridge transport fault is a third thing again. See Error Handling.
API
createIndeRunCapacitor(options)— returns a handle that lazilyconfigure()s on the firstrun()orstream()and memoizes it. Safe to call once at app startup.run(request)— Mode 1. Resolves with the canonical IndeRunTaskResult.stream(request)— Mode 2. Resolves with aStreamRun(handle,events,cancel).eventsis single-use.checkCapabilities()— every registered provider's static declaration and live availability, without executing a task. Resolves withProviderCapabilitySnapshot[]. Use it for a provider or settings screen; availability changes between calls, so do not cache it across arun()orstream().- The low-level plugin methods
configure(options),startStream(options)andcancelStream(options)are also exported, along with the listener event namesSTREAM_EVENT_NAME("indeRunStreamEvent") andSTREAM_ERROR_NAME("indeRunStreamError").
The two listener event names are public contract. Native emits exactly these, and an app may attach its own listener to them; renaming one is a breaking change.
The IndeRunCapacitorPlugin and ConfigureOptions contracts — including the openAI,
systemModel and onnx bootstrap configs and the allowDirectOpenAIEndpoint flag — are
defined and documented in src/definitions.ts.
systemModel, onnx and allowDirectOpenAIEndpoint are web-only and ignored on iOS
and Android, which register their own on-device provider from configure() regardless.
systemModel registers the browser-managed on-device provider (Chrome's Prompt API) and is
what makes constraints.privacy = "local_required" routable in a browser. Both on-device
web providers are Mode 1 only.
onnx carries two caveats. It needs the consumer to install the optional
@huggingface/transformers peer dependency — this bridge does not declare it — and to
supply real model weights, because the web SDK's runtime injection seam is a function and
so cannot cross a JSON bridge hop: only the default Transformers.js runtime is reachable
through configure(), never the fixture runtime the upstream demos use offline. Register it
only when the weights are there; a provider that cannot load turns a clean routing refusal
into a provider error.
Two asymmetries in the low-level surface, both hidden by the ergonomic API:
run(request)passes the request at the options root, whilestartStream({ streamId, request })nests it, because that envelope also carries the bridge-local correlation id.stream()hides this.- The plugin method
checkCapabilities()resolves{ providers: [...] }rather than the array itself, because a Capacitor plugin method cannot resolve a top-level array on either native platform.IndeRunCapacitor.checkCapabilities()unwraps it, so app code sees the same array the three platform SDKs return.
ProviderCapabilitySnapshot and the ProviderDescriptor /
ProviderDynamicCapabilities it contains are declared in src/definitions.ts rather than
imported, because — unlike TaskRequest or StreamEvent — they are not generated
contracts: each platform SDK declares its own copy and there is no schema or validator for
them upstream. src/web.ts returns the web SDK's snapshots into the bridge's type uncast,
so the shapes staying identical is a compile error rather than a convention. Note
capabilities.streamingAvailable and cancellationAvailable are absent, not null,
when the runtime has nothing to add: absence means inherit the static declaration.
Platform Notes
- Web requires at least one provider to be registered from
configure(). The web SDK ships an OpenAI-compatible provider, a Web ONNX Runtime provider and a browser system-model provider, but only the OpenAI-compatible one declares streaming support — so alocal_requiredstream in a browser is refused at routing time, while alocal_requiredrun can be served on-device. - iOS always registers the Apple on-device provider and optionally registers OpenAI when configured.
- Android always registers the ML Kit on-device provider and optionally registers OpenAI when configured.
- Keep credentials behind
authContextRef. That keeps a secret out of the request payload and out of source; it does not make a key safe to ship, since anything an installed app or a browser can read, someone with that app or browser can read. For a key you own, put it behind a backend you control and pointendpointUrlat that — for browser apps, a same-origin proxy withauth: "none".
Current Limitations
- Mode 1
run()and Mode 2stream()are supported. Mode 3 sessions are not. - No backpressure across the bridge. Native pushes events; a slow consumer buys memory, not throttling, because events buffer in JS until they are read. Runs are finite and terminal-bounded, and any drop or throttle policy would be behaviour — which belongs in the engines, not in a bridge.
- Which providers can actually stream is decided upstream, not here; see the provider matrix.
- An unrecognized
StreamEvent.typeis passed through untouched rather than rejected, so a consumer built against an older contract revision keeps working when a newer one adds an event type. Ignore what you do not recognize. - No plugin-level credential management API is exposed in this first cut.
- The bridge is intentionally thin; cloud provider bootstrap still has to come from the app.
Error Handling
A streaming run has three distinct failure surfaces, and conflating them is the easiest mistake to make:
| Surface | When | How it reaches you |
| --- | --- | --- |
| Rejection | Request validation, or no streaming-capable provider | stream() rejects with an IndeRunError |
| Terminal error event | A provider failed, or the whole planned chain did | Not a rejection — for await completes normally and the last event is terminal with payload.outcome === "error" |
| Bridge fault | The transport lost or could not encode events | events throws an Internal IndeRunError |
So a run that fails after starting ends your loop normally. Branch on
event.payload.outcome to tell completion from failure from cancellation.
Errors thrown by configure() and run() conform to IndeRunError from
@independo/inderun-contracts; branch on error.errorClass (the shared error
taxonomy). The bridge unwraps the native error envelope before re-throwing, so
callers receive the same IndeRunError shape on every platform.
try {
await inderun.run(request);
} catch (error) {
if (error && typeof error === "object" && "errorClass" in error) {
// error.errorClass is one of the IndeRun error taxonomy values
}
}License
MIT. See LICENSE.
Sponsorship & Development
This project is sponsored by netidee and developed by Independo GmbH.
Explore more open-source tools and research from Independo.
