On-device AI in a Capacitor app with Apple Foundation Models
Call Apple’s on-device language model from a Capacitor app with a 60-line local plugin. Free inference, no API keys, works offline. Includes availability checks, the Swift plugin, the TypeScript bridge and the Android story.
Since iOS 26, every iPhone that supports Apple Intelligence ships with a language model your app can call directly through the FoundationModels framework. It runs on the device, works offline, costs nothing per request, and user data never leaves the phone. For summaries, classification, tagging, short rewrites and extracting structured data from text, it is often good enough to replace a cloud API call.
There is no official Capacitor plugin for it, and you do not need one. A local plugin inside your app is about sixty lines of Swift. This tutorial builds it, wires it to TypeScript, and covers what to do on devices that do not have the model.
When on-device AI is the right choice
| Good fit on device | Better in the cloud |
|---|---|
| Summarize a note, email or article | Long multi-step reasoning |
| Classify or tag user content | Broad world knowledge and recent facts |
| Extract fields from free text | Large documents or long conversations |
| Suggest titles, replies, rewrites | Anything that must behave identically on every device |
| Features that must work offline or stay private | Users on older or unsupported phones |
The usual pattern is both: use the on-device model when it is available and the task is small, and fall back to your server otherwise.
1. Write the Swift plugin
Create ios/App/App/LocalAIPlugin.swift in your Capacitor project and add it to the App target in Xcode:
import Capacitor
import Foundation
#if canImport(FoundationModels)
import FoundationModels
#endif
@objc(LocalAIPlugin)
public class LocalAIPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "LocalAIPlugin"
public let jsName = "LocalAI"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "generate", returnType: CAPPluginReturnPromise),
]
@objc func isAvailable(_ call: CAPPluginCall) {
#if canImport(FoundationModels)
if #available(iOS 26.0, *) {
switch SystemLanguageModel.default.availability {
case .available:
call.resolve(["available": true])
case .unavailable(let reason):
call.resolve(["available": false, "reason": String(describing: reason)])
}
return
}
#endif
call.resolve(["available": false, "reason": "unsupportedOS"])
}
@objc func generate(_ call: CAPPluginCall) {
guard let prompt = call.getString("prompt") else {
call.reject("prompt is required")
return
}
let instructions = call.getString("instructions") ?? ""
#if canImport(FoundationModels)
if #available(iOS 26.0, *) {
Task {
do {
let session = LanguageModelSession(instructions: instructions)
let response = try await session.respond(to: prompt)
call.resolve(["text": response.content])
} catch {
call.reject(error.localizedDescription)
}
}
return
}
#endif
call.reject("On-device model requires iOS 26 or later")
}
}availability tells you why the model is missing: the device is not eligible, Apple Intelligence is turned off, or the model is still downloading. Pass that reason to JavaScript so the UI can respond sensibly.
2. Register the plugin
Local plugins are registered from a view controller. Create ios/App/App/MainViewController.swift:
import Capacitor
class MainViewController: CAPBridgeViewController {
override open func capacitorDidLoad() {
bridge?.registerPluginInstance(LocalAIPlugin())
}
}Then open Main.storyboard, select the Bridge View Controller, and set its custom class to MainViewController in the Identity inspector.
3. The TypeScript bridge
// src/native/local-ai.ts
import { Capacitor, registerPlugin } from '@capacitor/core';
interface LocalAIPlugin {
isAvailable(): Promise<{ available: boolean; reason?: string }>;
generate(options: { prompt: string; instructions?: string }): Promise<{ text: string }>;
}
const LocalAI = registerPlugin<LocalAIPlugin>('LocalAI');
export async function canUseLocalAI() {
if (Capacitor.getPlatform() !== 'ios') return false;
const { available } = await LocalAI.isAvailable();
return available;
}
export async function summarize(text: string) {
if (await canUseLocalAI()) {
const { text: summary } = await LocalAI.generate({
instructions: 'Summarize the user text in two short sentences. Plain language, no preamble.',
prompt: text,
});
return summary;
}
// Fallback: your server-side model
const res = await fetch('/api/summarize', { method: 'POST', body: JSON.stringify({ text }) });
return (await res.json()).summary as string;
}Call it like any other function:
const summary = await summarize(note.body);
4. Things to know before shipping
- Keep prompts short. The on-device model has a much smaller context window than cloud models. Summarize a note, not a book.
- Write instructions carefully. Small models follow clear, specific instructions far better than open-ended ones. Say what format you want and how long.
- Handle errors. Requests can fail for content policy reasons or when the context is exceeded. Always have a fallback path.
- Test on real hardware. Model availability depends on the device and the user's Apple Intelligence settings, which the simulator only partly reflects.
- Structured output is supported by the framework through Swift types marked
@Generable. Once plain text works, it is the natural next step for extraction tasks.
What about Android?
Google offers on-device generation with Gemini Nano on supported Android devices through its ML Kit GenAI APIs. The structure is the same: a small Kotlin plugin with isAvailable and generate, registered in MainActivity. Because both plugins expose the same TypeScript interface, the rest of your app does not need to know which model answered.
The part you will change most is not native. The plugin is written once. The instructions, the prompt format, when to use local versus cloud, and how the result is shown all live in TypeScript. Expect to tune them for weeks after launch.
Tune prompts without a store release
Prompt engineering is iterative. A slightly different instruction can turn a mediocre summary into a great one, and you only find out with real user content. If every tweak needs App Review, you get a few iterations a month.
With OtaKit, the plugin ships once in a store build, and every prompt change after that is a web update your users get on their next launch. Test new instructions on a staging channel first, then roll them out to everyone. For more on building AI features with Capacitor, see Capacitor for AI mobile apps.