Tutorial9 min read

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 deviceBetter in the cloud
Summarize a note, email or articleLong multi-step reasoning
Classify or tag user contentBroad world knowledge and recent facts
Extract fields from free textLarge documents or long conversations
Suggest titles, replies, rewritesAnything that must behave identically on every device
Features that must work offline or stay privateUsers 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.

Related docs