DEV Community

Robin for Capawesome

Posted on Originally published at capawesome.io

Apple Intelligence in a Capacitor App: On-Device Text Generation

Sending user text to a hosted language model costs money on every request, adds latency, and means notes and messages leave the phone. Since iOS 26, Apple's Foundation Models framework opens the on-device model behind Writing Tools to third-party apps, so a summarize button can run offline, for free, with no API key to rotate. Here is how to reach that model from a Capacitor app without writing Swift.

Full disclosure: the Capacitor LLM plugin used throughout this post is ours, and it is part of Capawesome Insiders, a paid subscription. You need a license key to install it from the Capawesome npm registry. Apple's model itself costs nothing per request. This is the condensed version of How to Use Apple Intelligence in a Capacitor App from our blog.

What the plugin exposes

The plugin wraps the Foundation Models framework in one TypeScript API. From your web code you get chat sessions that keep conversation context and carry instructions, token streaming through an event, cancellation of a running generation, a typed availability status, and per-chat or per-request temperature and maxOutputTokens.

Two things Apple's framework offers are not exposed yet: guided generation into typed structures and tool calling. Plan for free-text responses.

Inference runs on the Neural Engine. The plugin bundles no model files, makes no network calls, and adds no data type to your privacy nutrition label. It calls only public platform APIs, so there is nothing extra to explain to App Review.

What you need

Hardware alone does not decide whether the model runs. Four other conditions have to hold as well:

Requirement Detail
Device iPhone 15 Pro or later, iPad mini (A17 Pro) or M1 iPads and later, Apple silicon Macs, Apple Vision Pro. Apple keeps the current list on its Apple Intelligence support page.
OS version iOS 26, iPadOS 26, macOS 26, or visionOS 26. Apple Intelligence shipped with iOS 18.1, but the developer API did not.
Apple Intelligence turned on The user enables it under Settings > Apple Intelligence & Siri. Until then the plugin reports not-enabled.
Storage About 7 GB of free space for the model download, which the system manages.
Build toolchain Xcode 26 or later, plus Capacitor 8 for the plugin itself.

On iOS 18 every method except getAvailability() rejects as unavailable. On an iPhone 15 or older, the status is device-not-eligible. Both are normal states your UI has to handle, which is where the first step comes in.

Step 1: Check availability before showing the feature

getAvailability() never rejects. On iOS it resolves with one of five statuses, and each one maps to a different UI decision:

Status Meaning What to do
available The model is ready. Enable the feature.
device-not-eligible The hardware cannot run Apple Intelligence. Hide the feature or fall back to a server.
not-enabled Apple Intelligence is off in Settings. Explain how to turn it on, then check again.
not-ready The system is preparing or downloading the model. Show a waiting state.
unavailable No system model on this OS version. Hide the feature or fall back to a server.

A small helper turns the status into something a component can render:

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

const checkAppleIntelligence = async () => {
  const { status } = await Llm.getAvailability();
  switch (status) {
    case 'available':
      return { enabled: true };
    case 'not-enabled':
      return { enabled: false, hint: 'Turn on Apple Intelligence in Settings to use this feature.' };
    case 'not-ready':
      return { enabled: false, hint: 'The on-device model is still being prepared. Try again in a moment.' };
    default:
      return { enabled: false };
  }
};
Enter fullscreen mode Exit fullscreen mode

The status changes while your app is open, for example when the user enables Apple Intelligence in Settings and switches back. Listen for the availabilityChange event and re-run the check when it fires:

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

const watchAvailability = async (onChange: (status: string) => void) => {
  return Llm.addListener('availabilityChange', (event) => {
    onChange(event.status);
  });
};
Enter fullscreen mode Exit fullscreen mode

The plugin watches the system status only while a listener is attached, so remove it when the screen goes away.

Step 2: Create a chat with instructions

Every generation belongs to a chat. On iOS a chat is backed by a native model session that keeps the conversation context, so a follow-up prompt can refer to the previous answer. Pass instructions to define the role and the output format:

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

const createSummaryChat = async () => {
  const { id } = await Llm.createChat({
    instructions:
      'You summarize notes into at most three short bullet points. Keep the language of the note.',
  });
  return id;
};
Enter fullscreen mode Exit fullscreen mode

The id is optional, and passing your own (one per document, say) makes a second createChat(...) with the same id reject with CHAT_ALREADY_EXISTS. Chats live in memory and each one holds a native session, so delete a chat when the user leaves the screen with Llm.deleteChat({ id }). Deleting also cancels a generation still running in that chat.

Step 3: Generate a response

generateText(...) sends a prompt into a chat and resolves with the complete response:

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

const summarize = async (chatId: string, note: string) => {
  const { text } = await Llm.generateText({
    chatId,
    prompt: `Summarize this note:\n\n${note}`,
  });
  return text;
};
Enter fullscreen mode Exit fullscreen mode

One generation runs per chat at a time. Starting a second before the first resolves rejects with GENERATION_IN_PROGRESS, so disable the submit button while a request is pending, or give each task its own chat.

Step 4: Stream tokens into the UI

For anything longer than a sentence, waiting for the full response feels broken. streamText(...) emits a textChunk event for every piece and still resolves with the complete text at the end. Filter by chatId, because chunks from all chats arrive through the same listener:

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

const streamSummary = async (chatId: string, note: string, onUpdate: (text: string) => void) => {
  let output = '';
  const listener = await Llm.addListener('textChunk', (event) => {
    if (event.chatId === chatId) {
      output += event.text;
      onUpdate(output);
    }
  });
  try {
    const { text } = await Llm.streamText({
      chatId,
      prompt: `Summarize this note:\n\n${note}`,
    });
    return text;
  } finally {
    await listener.remove();
  }
};
Enter fullscreen mode Exit fullscreen mode

Append the chunks in arrival order. The resolved text equals the concatenated chunks, so use whichever fits your rendering.

Step 5: Cancel a generation

A user who can watch a response arrive will want to stop it. cancelGeneration(...) stops the running generation of a chat, and the pending promise rejects with GENERATION_CANCELED, which you should treat as a normal outcome rather than an error:

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

const stop = async (chatId: string) => {
  await Llm.cancelGeneration({ chatId });
};

const summarizeWithCancel = async (chatId: string, note: string) => {
  try {
    const { text } = await Llm.generateText({ chatId, prompt: note });
    return text;
  } catch (error) {
    if ((error as { code?: string }).code === 'GENERATION_CANCELED') {
      return null;
    }
    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode

On iOS the cancellation takes effect immediately, and the chat stays usable afterwards.

Limits to plan for

Two parameters shape the output. temperature controls how deterministic it is, maxOutputTokens caps its length, and both can be set per chat and overridden per request. Their ranges differ by platform:

Parameter iOS (Apple Intelligence) Android (Gemini Nano)
temperature Values above 1.0 are allowed. Between 0.0 and 1.0.
maxOutputTokens No documented hard limit. At most 4096.
Context size About 4,096 tokens per chat session. Input under about 4,000 tokens.

The context window is the limit you hit first. Instructions, every prompt, and every response count against it, and once it overflows the next generation rejects with GENERATION_FAILED. The same code appears when Apple's guardrails block a prompt or a response, so read the error message for the platform reason and show the user something better than a silent failure. For a summarizer, one chat per document keeps the window from filling up.

The same code runs on Android

The plugin uses Gemini Nano through the ML Kit GenAI Prompt API on Android, so the create, generate, stream, and cancel calls above need no changes. Availability differs: Android reports available, downloadable, downloading, and unavailable, and on downloadable your app triggers the download itself. Chat history is kept in memory by the plugin, because the Android API has no native multi-turn sessions, and cancellation is best-effort, so a few extra chunks can still arrive.

Wrapping up

Gate the feature on getAvailability(), ship generateText(...) first, then add streaming and a cancel button together once responses grow past a sentence. Keep one chat per task and the 4,096-token window stays out of your way.

The API reference for every method and event is in the Capacitor LLM plugin documentation, and the long-form walkthrough with the full note summarizer class, an availability troubleshooting table, and Simulator notes is in How to Use Apple Intelligence in a Capacitor App. If you build something with the on-device model, I would like to read about it in the comments.

Top comments (0)