---
title: Announcing the Capacitor LLM Plugin
description: The new Capacitor LLM plugin runs prompts on-device through Apple Intelligence and Gemini Nano, with chat sessions, token streaming and cancellation.
date:
  created: 2026-09-11
  updated: 2026-09-11
authors:
  - robingenz
categories:
  - Announcements
  - Capacitor
  - SDKs
links:
  - Capacitor LLM: sdks/capacitor/llm.md
faq: true
---

# Announcing the Capacitor LLM Plugin

Adding an AI feature to a mobile app usually means sending the user's text to a server. The [Capacitor LLM plugin](../../sdks/capacitor/llm.md) takes the other route. It prompts the language model that already ships with the operating system (Apple Intelligence on iOS, Gemini Nano on Android), so prompts and responses stay on the phone, there is no API key in your bundle, and there is no per-token bill. One TypeScript API covers both platforms with chat sessions, token streaming, cancellation and typed availability checks. The plugin is available today to all Capawesome [Insiders](../../insiders/index.md), a paid subscription.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Build and deploy your Capacitor app with Capawesome Cloud" src="https://capawesome.io/assets/banners/cloud-build-and-deploy-capacitor-apps.png?t=1" />
  </a>
</div>

## Key Takeaways

- `@capawesome-team/capacitor-llm` runs prompts on the system model: Apple Intelligence through the Foundation Models framework on iOS, Gemini Nano through the ML Kit GenAI Prompt API and AICore on Android.
- All inference happens on the device. There are no model files to bundle, no API keys to manage and no network calls to make.
- `getAvailability()` returns one of seven typed statuses and never rejects. Android reports four of them, iOS reports five.
- Chats carry instructions and conversation context, `streamText(...)` emits chunks through the `textChunk` event, and `cancelGeneration(...)` stops a running generation.
- The platform limits differ: Android caps `maxOutputTokens` at 4096 and `temperature` at 1.0, and both platforms work with a context window of roughly 4,000 tokens.
- Requires Capacitor 8, Android API level 26, iOS 26 and Xcode 26. It is part of the Capawesome Insiders subscription.

## Why Run the Model on the Device?

On-device inference takes the server out of the picture. The prompt, the conversation history and the generated response never leave the app's process, which changes several things at once:

- **Privacy.** Nothing is transmitted, so there is no third-party service processing user text and no request log to reason about.
- **Cost.** Inference runs on hardware the user already paid for. A feature that would cost you per token now costs nothing per request.
- **Offline.** Generation works in airplane mode, on a plane, in a basement, or on a warehouse floor with no reception.
- **No key management.** There is no API key embedded in the app bundle for someone to extract and spend.
- **App size.** The model is part of the operating system, so shipping this feature adds nothing to your download size.

There are trade-offs. On-device models are small compared to a frontier model behind an HTTP endpoint, the usable context is roughly 4,000 tokens on both platforms, and the hardware requirements exclude a large part of the installed base today. For summarizing a note, drafting a reply or rewriting a paragraph, that envelope is generous. For long-document reasoning, it is not.

## One TypeScript API Over Two System Models

The plugin doesn't ship a model or a runtime. It calls the model vendor's own on-device API on each platform and normalizes the result into one interface:

| Platform | Model | Underlying API | Requirements |
| --- | --- | --- | --- |
| Android | Gemini Nano | [ML Kit GenAI Prompt API](https://developers.google.com/ml-kit/genai/prompt/android){:target="_blank"} via AICore | API level 26 or later, a [Gemini Nano-capable device](https://developers.google.com/ml-kit/genai#supported_devices){:target="_blank"} |
| iOS | Apple Intelligence | [Foundation Models](https://developer.apple.com/documentation/foundationmodels){:target="_blank"} framework | iOS 26 or later, iPhone 15 Pro or later with Apple Intelligence enabled, Xcode 26 to build |
| Web | none | none | `getAvailability()` resolves with `unavailable`, every other method rejects as unimplemented |

Two details shape how you plan a release around this. Google's ML Kit GenAI Prompt SDK is still in beta, so breaking changes in the underlying SDK are possible; the plugin pins `com.google.mlkit:genai-prompt` at `1.0.0-beta2` and lets you override it with the `$mlkitGenaiPromptVersion` Gradle variable. And the list of Gemini Nano-capable devices is short today, roughly the Google Pixel 9 series and the Samsung Galaxy S25 series, which makes a runtime availability check mandatory rather than optional.

If you only care about Gemini Nano and only ship Android, the free [Capacitor ML Kit GenAI Prompt plugin](../../sdks/capacitor/mlkit/genai-prompt.md) wraps the same Google API directly. It shipped with [Capacitor ML Kit 8.2.0](./capacitor-mlkit-8-2-0-release.md) alongside five other GenAI plugins for summarization, proofreading, rewriting, image description and speech recognition. For a step-by-step walkthrough of the iOS side, our guide on [Apple Intelligence in a Capacitor app](./how-to-use-apple-intelligence-in-a-capacitor-app.md) covers the Foundation Models path in detail.

## Installation

To install the Capacitor LLM plugin, please refer to the [Installation](../../sdks/capacitor/llm.md/#installation) section in the plugin documentation. It is published to the Capawesome npm registry, so installing it requires the license key that comes with a [Capawesome Insiders](../../insiders/index.md) subscription.

The setup is short on both platforms. Android needs `minSdkVersion = 26` in `android/variables.gradle` and, if you use Proguard, one keep rule for the plugin's classes. The ML Kit SDK arrives as a regular Gradle dependency from Google's Maven repository, and the model itself is managed by AICore rather than bundled with your app. iOS needs nothing at all, since Foundation Models is part of the operating system; you only need Xcode 26 or later to build.

## Availability Is the First Call You Make

[`getAvailability()`](../../sdks/capacitor/llm.md#getavailability) never rejects. On a platform or OS version without a system model it resolves with `unavailable`, which means you can call it unconditionally at startup and branch on the result. There are seven statuses, and each one maps to a different thing your UI should do:

| Status | Meaning | Reported on | What you do |
| --- | --- | --- | --- |
| `available` | The model is ready to use. | Android, iOS | Start generating. |
| `device-not-eligible` | The device hardware does not support the model. | iOS | Offer a fallback, for example a cloud model. |
| `downloadable` | The model can be downloaded. | Android | Call `downloadModel()`. |
| `downloading` | The model is being downloaded right now. | Android | Show progress and wait. |
| `not-enabled` | The model is disabled on the device. | iOS | Ask the user to turn on Apple Intelligence in Settings. |
| `not-ready` | The system is still preparing the model. | iOS | Try again later. |
| `unavailable` | No system model exists on this platform or OS version. | Android, iOS, Web | Hide the feature or fall back. |

The status is not static. A user can enable Apple Intelligence in Settings, or an Android download can finish, while your app is open. The `availabilityChange` event covers that case, and the plugin only watches the status while at least one listener is attached:

```typescript
import { Llm } from '@capawesome-team/capacitor-llm';

const watchAvailability = async () => {
  const { status } = await Llm.getAvailability();
  updateUi(status);
  await Llm.addListener('availabilityChange', event => {
    updateUi(event.status);
  });
};
```

Treat the four Android statuses and the five iOS statuses as one set in your code. Writing a switch over all seven costs nothing and survives a device that reports a status you did not expect on that platform.

## Downloading Gemini Nano on Android

When the status is `downloadable`, the model has to reach the device before anything can be generated. Android is the only platform where your app triggers that: [`downloadModel()`](../../sdks/capacitor/llm.md#downloadmodel) starts the download and resolves when it completes, and the `downloadProgress` event reports a value between `0` and `1` along the way. On iOS the system manages the model, so there is nothing for the app to start.

Surface that progress in your UI, since the download runs long enough that a silent button reads as a broken one:

```typescript
import { Llm } from '@capawesome-team/capacitor-llm';

const downloadModel = async () => {
  await Llm.addListener('downloadProgress', event => {
    setProgress(event.progress);
  });
  await Llm.downloadModel();
};
```

## Chats Keep the Conversation Context

A chat is the unit of context. [`createChat(...)`](../../sdks/capacitor/llm.md#createchat) returns an identifier you pass to every generation, and it optionally takes `instructions`, the system prompt that shapes the model's tone and role for the whole conversation. Pass your own `id` if you want to map chats to your app's data model; leave it out and the plugin generates a UUID. Creating a chat with an identifier that already exists rejects with `CHAT_ALREADY_EXISTS`.

The implementation differs under the hood. On iOS, each chat is backed by a native language model session that holds the conversation. On Android, the system API has no multi-turn session concept, so the plugin keeps the history in memory and includes it in each prompt. The consequence is the same on both platforms: history lives in memory only and does not survive an app restart, and every chat holds native resources until you release them.

Deleting a chat you no longer need is therefore part of the normal flow:

```typescript
import { Llm } from '@capawesome-team/capacitor-llm';

const startChat = async () => {
  const { id } = await Llm.createChat({
    instructions: 'You are a helpful assistant that answers briefly.',
  });
  return id;
};

const endChat = async (chatId: string) => {
  await Llm.deleteChat({ id: chatId });
};
```

[`deleteChat(...)`](../../sdks/capacitor/llm.md#deletechat) also cancels an in-flight generation for that chat, so tearing down a chat screen while a response is still streaming is safe.

## Generating and Streaming Text

There are two ways to get text out. [`generateText(...)`](../../sdks/capacitor/llm.md#generatetext) resolves with the complete response and suits short outputs where a spinner is fine. [`streamText(...)`](../../sdks/capacitor/llm.md#streamtext) emits the response chunk by chunk through the `textChunk` event and resolves with the full text at the end, which is what you want anywhere the user is watching the answer appear.

Each `textChunk` event carries the `chatId` it belongs to, so one listener can serve several chats as long as you route by that field and append the chunks in the order they arrive:

```typescript
import { Llm } from '@capawesome-team/capacitor-llm';

const streamAnswer = async (chatId: string) => {
  await Llm.addListener('textChunk', event => {
    if (event.chatId === chatId) {
      appendToUi(event.text);
    }
  });
  const { text } = await Llm.streamText({
    chatId,
    prompt: 'Summarize this note in three bullet points.',
  });
  return text;
};
```

Only one generation can run per chat at a time. Sending a second prompt into a chat that is still generating rejects with `GENERATION_IN_PROGRESS` rather than queueing it, so disable the send button while a response is in flight, or run parallel work in separate chats.

## Canceling a Generation

Users change their mind, and a small model can spend several seconds on an answer nobody wants any more. [`cancelGeneration(...)`](../../sdks/capacitor/llm.md#cancelgeneration) stops the current generation of a chat, and the pending `generateText(...)` or `streamText(...)` promise rejects with `GENERATION_CANCELED`, which you handle as a normal outcome rather than an error to report.

The semantics differ by platform. On iOS the generation stops immediately. On Android cancellation is best-effort: the plugin interrupts the generation as soon as it can, but a few more `textChunk` events may still arrive afterwards. Track a canceled flag per chat and drop chunks that arrive after the user pressed stop:

```typescript
import { Llm } from '@capawesome-team/capacitor-llm';

const canceledChatIds = new Set<string>();

const stopGeneration = async (chatId: string) => {
  canceledChatIds.add(chatId);
  await Llm.cancelGeneration({ chatId });
};
```

## What the Platforms Let You Tune

The plugin exposes two generation parameters. `maxOutputTokens` caps the length of a response, and `temperature` controls how deterministic it is. Both can be set as chat defaults in `createChat(...)` and overridden per request in `generateText(...)` or `streamText(...)`, so a chat can run at a low temperature for factual answers and raise it for a single creative prompt.

The ranges those parameters accept come from the platforms, not from the plugin:

| Parameter | Android (Gemini Nano) | iOS (Apple Intelligence) |
| --- | --- | --- |
| `maxOutputTokens` | Limited to a maximum of `4096` tokens. | No documented hard limit. |
| `temperature` | Must be between `0.0` and `1.0`. | Values greater than `1.0` are allowed. |
| Context size | Input must be under ~4,000 tokens. | Context window of ~4,096 tokens per session. |

Keep a chat's parameters inside the Android ranges if you want one code path across both platforms. The context limit is the one to design around: a long conversation eventually exceeds it, the generation rejects with `GENERATION_FAILED`, and the fix is to start a new chat, optionally seeded with a summary of the old one that you generated while there was still room.

```typescript
import { Llm } from '@capawesome-team/capacitor-llm';

const createFactualChat = async () => {
  const { id } = await Llm.createChat({
    maxOutputTokens: 256,
    temperature: 0.2,
  });
  return id;
};
```

## Error Codes to Handle

Failures come back as typed error codes on the rejected promise, so you can branch on them instead of matching message strings:

| Code | When it happens | What to do |
| --- | --- | --- |
| `CHAT_ALREADY_EXISTS` | `createChat(...)` was called with an `id` that is already in use. | Reuse the existing chat or pick another identifier. |
| `GENERATION_IN_PROGRESS` | A second prompt was sent into a chat that is still generating. | Wait for the first generation, or use another chat. |
| `GENERATION_CANCELED` | The generation was stopped by `cancelGeneration(...)` or `deleteChat(...)`. | Treat it as a normal outcome, not an error. |
| `GENERATION_FAILED` | The context window was exceeded, the platform's safety guardrails blocked the content, or the AICore quota ran out on Android. | Read the message for the platform reason. Start a new chat when the context overflowed. |

## What You Can Build With It

The obvious candidates are the features where the input is the user's own text and shipping it to a server is the part you would rather avoid: summarizing notes, articles or long email threads, suggesting replies in a chat or support inbox, rewriting and proofreading what the user typed, and assistants that keep working without reception.

The plugin also pairs with the rest of the Capawesome catalog for a fully on-device voice loop. The [Capacitor Speech Recognition plugin](../../sdks/capacitor/speech-recognition.md) turns the user's voice into a prompt, the LLM plugin generates the answer, and the [Capacitor Speech Synthesis plugin](../../sdks/capacitor/speech-synthesis.md) reads it aloud. If you want to persist the conversation between launches, the [Capacitor Secure Preferences plugin](../../sdks/capacitor/secure-preferences.md) stores it encrypted on the device.

## How Does the Capacitor LLM Plugin Compare?

Several Capacitor plugins now target on-device generation, and they make genuinely different bets. This table reflects only what each project documents in its README at the time of writing:

| Plugin | Models | Platforms | Streaming | Cancellation | Availability states |
| --- | --- | --- | --- | --- | --- |
| [`@capawesome-team/capacitor-llm`](../../sdks/capacitor/llm.md) | Apple Intelligence, Gemini Nano | Android, iOS | `textChunk` event | `cancelGeneration(...)` | 7 typed statuses |
| [`@capacitor/local-llm`](https://github.com/ionic-team/capacitor-local-llm){:target="_blank"} | Apple Intelligence, Gemini Nano, plus iOS image generation | Android, iOS | Not documented | Not documented | 4 statuses |
| [`@capacitor-mlkit/genai-prompt`](../../sdks/capacitor/mlkit/genai-prompt.md) | Gemini Nano | Android | `inferenceProgress` partial results | Not documented | Feature status check |
| [`@capgo/capacitor-llm`](https://github.com/Cap-go/capacitor-llm){:target="_blank"} | Apple Intelligence, Gemini Nano, custom LiteRT-LM models, web models via MediaPipe | Android, iOS, Web | Chunked events | Not documented | Not documented |

Ionic's `@capacitor/local-llm` is labeled experimental in its own README, with the note that support is not provided, and it clamps `maxOutputTokens` to 1–256 on Android, which rules out longer answers there. It does offer something we don't, namely image generation on iOS through Image Playground. Capgo's plugin is the one to look at if you want to load your own `.litertlm` model files or need a web implementation, at the cost of bundling or downloading model weights. The ML Kit GenAI Prompt plugin is free and open source but Android-only by design.

Our plugin makes the narrower bet: system models only, both mobile platforms, with the parts a production chat UI needs (streaming, cancellation, typed availability, per-chat parameters) and priority support behind it. It is built exclusively on public platform APIs, so there is nothing for App Review to object to and nothing that breaks when the OS moves on.

## FAQ

### Can a Capacitor app run an LLM without an internet connection?

Yes. The Capacitor LLM plugin runs Apple Intelligence on iOS and Gemini Nano on Android entirely on the device, so generation works with no network connection. The one step that needs connectivity is the initial Gemini Nano download on Android, which the system performs once when you call `downloadModel()`.

### Which devices support on-device text generation?

On iOS, Apple Intelligence-enabled devices (iPhone 15 Pro or later) running iOS 26 or later, with Apple Intelligence turned on. On Android, [Gemini Nano-capable devices](https://developers.google.com/ml-kit/genai#supported_devices){:target="_blank"} with AICore, such as the Google Pixel 9 series or the Samsung Galaxy S25 series. Always check `getAvailability()` at runtime and design a fallback.

### Does the plugin work on the web?

No. Browsers provide no system language model, so `getAvailability()` resolves with `unavailable` and every other method rejects with an unimplemented error.

### Can I use my own model files?

No. The plugin supports only the system-provided models, which is what keeps your app size unchanged and inference fast. Support for custom model runtimes may be added later.

### Why does a generation fail with `GENERATION_FAILED`?

The usual causes are an exceeded context window, content blocked by the platform's safety guardrails, or an exceeded AICore quota on Android. The error message carries the platform-specific reason. When the context overflowed, create a new chat.

### Is the Capacitor LLM plugin free?

No. It is part of the Capawesome [Insiders](../../insiders/index.md) subscription, which also covers every other Insiders plugin and priority support.

### Does it work with Ionic, Angular, React or Vue?

Yes. The plugin is framework-agnostic and works in any Capacitor app, including Ionic with Angular, React or Vue, as well as plain JavaScript projects.

## Get Started

The Capacitor LLM plugin is available today to all Capawesome [Insiders](../../insiders/index.md) and requires Capacitor 8 or later. It supports Android and iOS, and it ships with CocoaPods and Swift Package Manager support. New plugins and notable releases go out in the Capawesome newsletter first.

[Subscribe to the Capawesome Newsletter](https://capawesome.io/newsletter/){ .md-button .md-button--primary }

## Conclusion

Start with `getAvailability()` and a fallback path, because on most devices in the wild today the answer is still `unavailable`. Once you have that gate, wire up `generateText(...)` for a first feature, then move to `streamText(...)` and `cancelGeneration(...)` when the UI needs them. If iOS is your first target, our guide on [using Apple Intelligence in a Capacitor app](./how-to-use-apple-intelligence-in-a-capacitor-app.md) walks through the whole flow on a device, and the [API reference](../../sdks/capacitor/llm.md#api) documents every option.

**Missing a feature?** [Create a feature request](https://github.com/capawesome-team/capacitor-plugins/issues/new/choose){:target="_blank"} in our GitHub repository.

If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
