<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Sai Ram Kotha]]></title><description><![CDATA[Notes from a mobile systems architect on what AI costs and how it runs on phones: on-device models, token budgets, and the limits that shape real products.]]></description><link>https://sairamkotha.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 06:31:09 GMT</lastBuildDate><atom:link href="https://sairamkotha.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[On-Device vs Cloud AI: Build a Mobile Agent That Knows When to Ask]]></title><description><![CDATA[Design mobile AI routing, user-approved actions, safe retries, and evaluation across on-device and cloud models.]]></description><link>https://sairamkotha.hashnode.dev/on-device-vs-cloud-ai-build-a-mobile-agent-that-knows-when-to-ask</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/on-device-vs-cloud-ai-build-a-mobile-agent-that-knows-when-to-ask</guid><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sun, 01 Nov 2026 15:31:10 GMT</pubDate><content:encoded><![CDATA[<p><em>Route requests by capability and privacy, then make actions reviewable and recoverable.</em></p>
<p>A local model can summarize a note while the phone is offline. A cloud model can handle a more demanding request. Neither fact tells your app when it is allowed to send private content over the network—or whether a proposed action should run.</p>
<p>The architecture needs two separate decisions: where inference runs, and who authorizes an action.</p>
<h2>Start with an explicit routing policy</h2>
<p>For a meeting assistant, define rules before adding an automatic router:</p>
<ul>
<li>Local model ready and task supported: run locally.</li>
<li>Local model downloading: show readiness state and retain manual editing.</li>
<li>Local model unavailable and cloud processing allowed: offer or use the user's approved cloud path.</li>
<li>Cloud disallowed or network unavailable: keep a manual path.</li>
<li>Output fails validation: allow correction or retry; escalate only when permitted.</li>
</ul>
<p>Capability checks, user preferences, task type, input size, network state, quality, latency, and cost can all affect the decision. A model's verbal confidence is not a calibrated correctness probability unless tested for that purpose.</p>
<p>The privacy boundary includes more than inference. Review logs, crash reports, analytics, retrieved content, and tool calls. A feature described as local should not silently upload the note through a fallback or telemetry path.</p>
<h2>Treat the agent as a workflow</h2>
<pre><code class="language-text">Selected note → local or cloud inference → proposed actions
              → validation → user preview → authorized execution
              → verify result → report outcome
</code></pre>
<p>A model can propose a task title and date. The app validates the fields, decides which tools are exposed, checks permissions, and executes through normal application code. A structured tool call is not authorization, and a valid date is not proof that the meeting note contained that date.</p>
<p>Use an allowlist of tools and validate their arguments at the execution boundary. Give each tool the least access needed. Treat notes, search results, and other retrieved content as untrusted data: text inside them cannot grant new permissions or override application policy.</p>
<h2>Design for interruption and uncertainty</h2>
<p>The user may navigate away, revoke permission, lose connectivity, or have the app terminated. Each operation should have an explicit state: proposed, approved, executing, confirmed, failed, or outcome unknown. Persist only what is required for recovery, protecting sensitive content appropriately.</p>
<p>Consider a task-creation call that succeeds on the server but times out before the client receives confirmation. Retrying without a stable idempotency key can create a duplicate. Use destination-supported idempotency where available; otherwise check state before retrying when feasible and surface uncertainty to the user.</p>
<p>Bound tool calls and runtime. Apple notes that tools can be called multiple times and in parallel; required tool-calling modes need an exit condition. <a href="https://developer.apple.com/documentation/foundationmodels/expanding-generation-with-tool-calling">Apple tool documentation</a></p>
<h2>Evaluate the system, not just the model</h2>
<p>Use a fixed set of meeting notes with explicit and missing dates, multiple tasks, no tasks, contradictory instructions, and injected instructions inside source text. Record:</p>
<ul>
<li>Grounded task and deadline accuracy.</li>
<li>Unsupported claims and appropriate abstentions.</li>
<li>User edits and approval rate.</li>
<li>Availability, latency, and fallback rate by device.</li>
<li>Duplicate actions, failed recoveries, and unknown outcomes.</li>
<li>Cloud cost per verified completed task, where applicable.</li>
</ul>
<p>Test offline mode, model unavailability, navigation during inference, app restart after an uncertain action, and permission changes. Keep prompts, model versions, device and OS versions with evaluation results so changes can be compared.</p>
<h2>A release criterion</h2>
<p>Ship only when the user can see what will happen, the application enforces the relevant permissions, failed or interrupted work has a recovery path, and you can measure whether proposed actions were correct. The model helps interpret intent; the application owns the boundary between a suggestion and an action.</p>
<p>Read Google's <a href="https://developers.google.com/ml-kit/genai">ML Kit GenAI overview</a>, Apple's <a href="https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models">Foundation Models generation guide</a>, and Google's <a href="https://github.com/android/ai-samples/blob/main/ai-sample-catalog/README.md">Android AI Sample Catalog</a> for platform-specific starting points.</p>
<p><em>Technical review: November 2026. Architecture is illustrative; verify implementation and platform behavior on target devices.</em></p>
]]></content:encoded></item><item><title><![CDATA[Run Gemini Nano on Android: A Practical ML Kit Guide for Mobile Developers]]></title><description><![CDATA[Build on-device Android AI with Gemini Nano, ML Kit, model availability checks, and lifecycle handling.]]></description><link>https://sairamkotha.hashnode.dev/run-gemini-nano-on-android-a-practical-ml-kit-guide-for-mobile-developers</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/run-gemini-nano-on-android-a-practical-ml-kit-guide-for-mobile-developers</guid><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sun, 01 Nov 2026 15:30:32 GMT</pubDate><content:encoded><![CDATA[<p><em>Build an on-device meeting assistant with availability checks, lifecycle handling, and meaningful evaluation.</em></p>
<p>A model integration is not complete when it generates one response on a test phone. It is complete when the app knows what to do on supported devices, unsupported devices, while the model downloads, and when inference fails.</p>
<p>This guide continues the meeting assistant from the AI Engineering Roadmap. Google’s ML Kit GenAI APIs access Gemini Nano through AICore. Device and feature support vary; check the <a href="https://developers.google.com/ml-kit/genai">current documentation</a> and test the actual configuration you intend to ship.</p>
<h2>Choose a task before choosing an API</h2>
<p>Our first task is narrow: summarize a selected note and propose action items. The user can review and edit the output before saving anything. If the note has no explicit deadline, the app must not present a guessed deadline as fact.</p>
<p>Google offers task-specific APIs and direct model access. Prefer a task-specific API when it fits the product; use direct prompting when your use case needs the additional flexibility. Check each feature's documented device requirements, terms, release stage, and limitations.</p>
<h2>Treat readiness as application state</h2>
<p><code>checkStatus()</code> can report <code>AVAILABLE</code>, <code>DOWNLOADABLE</code>, <code>DOWNLOADING</code>, or <code>UNAVAILABLE</code>. Android API-level support alone does not mean a particular model is ready on every device.</p>
<pre><code class="language-kotlin">// Call from an appropriate coroutine scope.
val model = Generation.getClient()
try {
    when (model.checkStatus()) {
        FeatureStatus.AVAILABLE -&gt; {
            val response = model.generateContent(
                "Summarize the supplied note without inventing facts."
            )
            // Validate and display the response.
        }
        FeatureStatus.DOWNLOADABLE -&gt; {
            // Explain the download and offer an appropriate next step.
        }
        FeatureStatus.DOWNLOADING -&gt; {
            // Show progress or keep the manual workflow available.
        }
        FeatureStatus.UNAVAILABLE -&gt; {
            // Offer a fallback.
        }
        else -&gt; {
            // Handle an unrecognized status.
        }
    }
} finally {
    model.close()
}
</code></pre>
<p>This is an API-shape example, not a complete application. A real implementation supplies the selected note, handles exceptions, owns the model at an appropriate lifecycle level, and calls <code>close()</code> when it is no longer needed. Google's <a href="https://developers.google.com/ml-kit/genai/prompt/android/get-started">direct model guide</a> documents setup, download and streaming; the <a href="https://developers.google.com/android/reference/com/google/mlkit/genai/prompt/GenerativeModel">API reference</a> documents resource cleanup.</p>
<h2>Download is a product experience</h2>
<p>A downloadable model is not yet usable. Explain why the download is needed, show progress where available, handle network loss and errors, and keep the user’s note intact. Do not assume a developer device immediately after setup reflects a normal user device: Google's guide describes AICore configuration and initialization delays.</p>
<p>Model availability can change after the initial check. Handle inference failure even when a previous status was <code>AVAILABLE</code>. If cloud fallback is permitted, disclose when note content leaves the device. Otherwise preserve a manual workflow.</p>
<h2>Protect the UI from stale results</h2>
<p>Store the active request ID and note ID in the component that owns generation. When the user starts a new request or changes notes, cancel old work where supported and reject results that no longer match the current request. An old inference completing late must never overwrite a newer note's result.</p>
<p>Check the token budget before sending large notes. The documented token limit includes input and output. Decide how to select or shorten context; do not silently truncate a note and present the result as comprehensive.</p>
<h2>Evaluate on devices</h2>
<p>Create examples with explicit tasks, missing deadlines, conflicting text, no action items, multiple languages supported by your feature, and irrelevant instructions embedded in a note. Measure unsupported claims, missed actions, and user edits separately from parsing failures.</p>
<p>Measure first-token and total latency, readiness rate, failures, memory, and repeated use on representative devices. Capture device model, OS, API and model configuration so a future update can be compared against the same workload. A single flagship result is not a device-coverage strategy.</p>
<h2>Agents are a separate integration</h2>
<p>Google also documents <a href="https://developers.google.com/ml-kit/genai/agent/android/get-started">agentic workflows</a>. Requirements and release status can differ from direct prompting. Recheck its guide before use. If a model can request task creation, validate arguments, enforce permissions in code, preview the action when appropriate, and prevent duplicate execution after retries.</p>
<p>For examples, study Google's <a href="https://github.com/googlesamples/mlkit/blob/master/android/genai/README.md">ML Kit GenAI quickstart</a> and <a href="https://github.com/android/ai-samples/blob/main/ai-sample-catalog/README.md">Android AI Sample Catalog</a>. Samples demonstrate APIs and need production-specific lifecycle, privacy, and error handling.</p>
<p><em>Technical review: November 2026. Sample has not been compiled or tested on a device.</em></p>
]]></content:encoded></item><item><title><![CDATA[Build an On-Device AI Feature on iOS With Foundation Models]]></title><description><![CDATA[Build an iOS AI feature with Foundation Models, availability checks, lifecycle handling, and evaluation.]]></description><link>https://sairamkotha.hashnode.dev/build-an-on-device-ai-feature-on-ios-with-foundation-models</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/build-an-on-device-ai-feature-on-ios-with-foundation-models</guid><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sun, 01 Nov 2026 15:28:20 GMT</pubDate><content:encoded><![CDATA[<p><em>Availability, structured output, session lifecycle, and evaluation for a local meeting assistant.</em></p>
<p>Your app can ask Apple's on-device model to turn meeting notes into action items. The difficult part is deciding what to do when the model is unavailable, produces an invented deadline, or is interrupted while the user is editing a different note.</p>
<p>This guide extends the meeting assistant from the AI Engineering Roadmap. The examples follow Apple's documented API patterns; compile and test against your chosen Xcode SDK and supported devices before shipping.</p>
<h2>Start with a narrow contract</h2>
<p>The input is one note selected by the user. The output is a proposed list of task titles and optional deadlines. Nothing is saved until the user reviews it.</p>
<p>A successful result must be grounded in the note. An empty list is valid. A deadline that is absent or ambiguous should stay empty; the model must not invent one. The app should present the original note beside the proposed items so the user can verify them.</p>
<h2>Check model availability</h2>
<p>The on-device model is not guaranteed to be available just because your app runs. Apple documents device eligibility, settings, regional support, and model readiness as factors. Check <code>SystemLanguageModel.default.availability</code> at the point you enable the feature, and handle a change between the check and inference.</p>
<pre><code class="language-swift">import FoundationModels

func summarize(_ note: String) async throws -&gt; String? {
    let model = SystemLanguageModel.default
    switch model.availability {
    case .available:
        let session = LanguageModelSession(
            instructions: "Summarize the note. Do not invent facts."
        )
        let response = try await session.respond(to: note)
        return response.content
    case .unavailable:
        return nil
    @unknown default:
        return nil
    }
}
</code></pre>
<p><code>nil</code> stands for an unavailable experience in this small example. In the actual product distinguish a model that is not ready from an ineligible device where the SDK exposes that reason. Offer the existing manual task flow. Do not silently send a private note to a cloud provider.</p>
<h2>Generate structure, then validate meaning</h2>
<p>Apple's guided generation supports Swift types marked <code>@Generable</code>. Model-generated structure reduces parsing work, but a string field called <code>deadline</code> can still contain an unsupported date. Check the generated title and date against your product rules and provide edit controls. When source grounding cannot be established automatically, show the relevant note and ask the user to verify it.</p>
<p>Avoid interpreting “next Friday” with an undocumented timezone or reference date. If your product resolves relative dates, provide an explicit reference date and timezone, use deterministic date logic where possible, and present the resolved value before saving.</p>
<h2>Own the session lifecycle</h2>
<p>A <code>LanguageModelSession</code> retains context. Apple says a session handles only one request at a time; disable overlapping submissions or serialize calls. For independent notes, decide whether to start fresh sessions to avoid carrying irrelevant context. Long sessions can exceed context limits, and supported languages may differ from your app's supported locales.</p>
<p>Give each generation a request ID and note ID. When a result arrives, verify both still match the visible request. Cancellation is useful but is not a substitute for checking identity: work may complete as the user navigates away.</p>
<p>Handle model errors as user-facing states: unavailable, unsupported language, context limit, cancellation, and general generation failure. Preserve the source note and manual editing path.</p>
<h2>Test what matters</h2>
<p>Build a set of notes covering explicit tasks, no tasks, missing dates, ambiguous dates, multiple names, contradictory instructions, and long input. For each proposed task, record whether the title is supported and whether the deadline was present in the source. Review errors by category; a well-formed response is not necessarily a correct one.</p>
<p>Measure cold and warm response time on representative supported devices. Test backgrounding, navigation, repeated taps, model-not-ready state, and OS updates. Do not publish device performance claims without recording hardware, OS, model, workload, and test method.</p>
<h2>Sources and next step</h2>
<p>Apple's <a href="https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models">generation guide</a>, <a href="https://developer.apple.com/documentation/foundationmodels/supporting-languages-and-locales-with-foundation-models">language support guide</a>, and <a href="https://developer.apple.com/documentation/updates/foundationmodels">Foundation Models updates</a> describe these APIs and limitations. Apple's <a href="https://developer.apple.com/documentation/foundationmodels/generate-dynamic-game-content-with-guided-generation-and-tools">guided generation and tools sample</a> is useful for exploring structured outputs.</p>
<p>Next: build the Android equivalent with ML Kit and Gemini Nano, including model download and cleanup states.</p>
<p><em>Technical review: November 2026. Sample has not been compiled or tested on a device.</em></p>
]]></content:encoded></item><item><title><![CDATA[7 On-Device AI Tools for Mobile, and Where Each One Stops]]></title><description><![CDATA[Seven tools for running AI on iPhone and Android, with the limits Apple and Google document, and when to skip each one.]]></description><link>https://sairamkotha.hashnode.dev/7-on-device-ai-tools-for-mobile-and-where-each-one-stops</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/7-on-device-ai-tools-for-mobile-and-where-each-one-stops</guid><category><![CDATA[on-device ai]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><category><![CDATA[Mobile Ai]]></category><category><![CDATA[Apple Foundation Models]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sun, 01 Nov 2026 13:11:20 GMT</pubDate><content:encoded><![CDATA[<p>Most "best AI tools" lists rank products by how impressive the demo looks. For a mobile app, the useful question is different: what does this tool run on the phone, what does it send off the phone, and where does the vendor's own documentation say it breaks? This list answers that for seven tools you can reach from an iPhone or Android app today.</p>
<p>Every entry has the same four parts: what it is, what it can do, the limit we found in the vendor's docs, and when to skip it. Where a claim comes from a vendor page, we say so. Where we could not confirm something, we say that too.</p>
<h2>How to read this list</h2>
<p>Three rules sit behind every entry:</p>
<ol>
<li><strong>Check availability at runtime.</strong> No phone model supports every on-device API. The same app can get a ready model on one device and an error on the next.</li>
<li><strong>Count the tokens the model can hold.</strong> Apple's on-device model has a fixed context window. Its documentation states that the limit includes instructions, prompts and outputs.</li>
<li><strong>Decide the fallback before you ship.</strong> If the phone model is missing, the app needs a clear answer: show a non-AI version, route to the cloud, or ask the user.</li>
</ol>
<p>For the reasoning behind where a feature should run, see <a href="https://sairamkotha.hashnode.dev/decide-per-feature-where-on-device-ai-runs">Part 4: Decide Per Feature, Not Per App</a>.</p>
<h2>1. Apple Foundation Models (<code>SystemLanguageModel</code>)</h2>
<p><strong>What it is.</strong> Apple's on-device language model, built into iOS 26 and later. You create a session, send instructions and a prompt, and read the reply. It needs no API key and no network call.</p>
<p><strong>What it does well.</strong> Summaries, rewrites, short extraction, and structured output that you define in Swift. The model runs on the device, so the text does not leave the phone for these calls.</p>
<p><strong>The limit.</strong> Apple's error documentation for <code>exceededContextWindowSize</code> says the context window is 4,096 tokens, and that the count "includes instructions, prompts, and outputs for a session instance." A chat feature with long instructions and tools can run out of room within a few turns.</p>
<p>Do not hardcode 4,096. Apple added <code>SystemLanguageModel.contextSize</code> to read the limit at runtime, and <code>tokenCount(for:)</code> to count tokens before you send. The WWDC26 sample prints a different number from a newer model version, so the limit depends on the OS and model you ship against.</p>
<p><strong>Availability.</strong> Check before you create a session. The main cases from Apple's documentation:</p>
<ul>
<li><code>deviceNotEligible</code>: the device does not support Apple Intelligence. Permanent. Show the non-AI version.</li>
<li><code>appleIntelligenceNotEnabled</code>: the user can turn it on in Settings. Prompt once. Do not nag.</li>
<li><code>assetsUnavailable</code>: the model's files can disappear while the app is running, for example if the user turns Apple Intelligence off. Apple says you may recover by retrying later.</li>
</ul>
<p><strong>Skip it when</strong> the feature needs long documents, multi-step reasoning, or a context that will not fit in a few pages of text. Route those to the cloud or to Private Cloud Compute (entry 5).</p>
<h2>2. Gemini Nano through the ML Kit GenAI Prompt API</h2>
<p><strong>What it is.</strong> Google's on-device model on Android, reached through the ML Kit GenAI Prompt API. The model runs inside AICore, a system service that also downloads and updates the model. Your app does not ship the model weights.</p>
<p><strong>What it does well.</strong> Text prompts on supported devices, offline, with the same model shared across apps through the system service. Google's Gemini Nano page says the model does not count against your app's disk and runtime memory budget. That statement covers the weights, not every runtime cost, so measure your own memory use.</p>
<p><strong>The limits.</strong></p>
<ul>
<li><strong>Model states.</strong> The Prompt API page lists AVAILABLE, DOWNLOADABLE, DOWNLOADING and UNAVAILABLE. A model that is not ready yet is a normal state. Handle every one of them.</li>
<li><strong>Quota.</strong> "AICore enforces an inference quota per app." Too many requests in a short time return <code>ErrorCode.BUSY</code>. Google suggests exponential backoff. A long-duration quota can return <code>PER_APP_BATTERY_USE_QUOTA_EXCEEDED</code>.</li>
<li><strong>Device list.</strong> Direct model access is limited to listed devices. The current nano-v3 group includes the Pixel 9 and Pixel 10 families and recent Galaxy S26 models. The Pixel 8 family is not in the direct-access list. The Galaxy S25 appears only in the list for task-specific APIs, not custom prompts. Which API you call decides which phones you reach.</li>
</ul>
<p><strong>Skip it when</strong> your minimum Android version or device mix cannot reach the listed devices, and you have no fallback. Test on the devices your users own, not on the one in your drawer.</p>
<h2>3. Firebase AI Logic hybrid inference</h2>
<p><strong>What it is.</strong> A Firebase SDK that routes one request between the phone model and a cloud Gemini model. You choose the mode when you create the model. The routing decision then sits in the SDK, not in your app.</p>
<p><strong>The four modes.</strong></p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Phone used when</th>
<th>If the phone cannot serve it</th>
</tr>
</thead>
<tbody><tr>
<td><code>PREFER_ON_DEVICE</code></td>
<td>The phone model is available and supports the request</td>
<td>Falls back to the cloud model</td>
</tr>
<tr>
<td><code>ONLY_ON_DEVICE</code></td>
<td>The phone model is available and supports the request</td>
<td>Throws an exception</td>
</tr>
<tr>
<td><code>ONLY_IN_CLOUD</code></td>
<td>Never. Always the cloud model</td>
<td>Throws an exception</td>
</tr>
</tbody></table>
<p>The Android configuration looks like this, adapted from the Firebase hybrid documentation:</p>
<pre><code class="language-kotlin">val onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE)
</code></pre>
<p><strong>The limit.</strong> The hybrid API is marked experimental in the Firebase docs at the time we checked. Check the current mode names and status before you ship. Also check whether a fallback logs an error. One Firebase page says it does; the configuration page does not mention logging.</p>
<p><strong>Skip it when</strong> you need the request to stay on the phone. <code>ONLY_ON_DEVICE</code> throws instead of falling back, so your app must handle that exception on every call.</p>
<h2>4. Android AICore as a direct runtime</h2>
<p><strong>What it is.</strong> The system service underneath entry 2. You do not usually call AICore directly, but its behaviour sets your app's limits: model downloads, updates, and the per-app quota.</p>
<p><strong>What to take from it.</strong> The download can take time on first use, the quota can refuse a request, and the model is shared across apps on the same device. Design the first run around a download in progress, not around an instant reply.</p>
<p><strong>Skip it when</strong> you expect the model to be present on every Android phone. It is not.</p>
<h2>5. Apple Private Cloud Compute</h2>
<p><strong>What it is.</strong> Apple's route for requests that exceed the on-device model. Apple's guidance is to start with the on-device model and move to Private Cloud Compute only if the feature needs more reasoning or context.</p>
<p><strong>The limits.</strong> Private Cloud Compute needs iOS 27 or later, a network connection, a device that supports Apple Intelligence, and an entitlement Apple grants after an eligibility review. You cannot turn it on in a day.</p>
<p><strong>Skip it when</strong> you need an answer today or your users are on older iOS versions. Plan the fallback first.</p>
<h2>6. ExecuTorch for your own models</h2>
<p><strong>What it is.</strong> PyTorch's runtime for running your own exported models on phones. Use it when the built-in models do not fit your feature and you ship a model you trained or converted.</p>
<p><strong>What the research says.</strong> A 2026 paper on KV-cache reuse on phones reports results on a Xiaomi 15 Pro running Qwen3-4B, quantized to Int4, on ExecuTorch. The authors report prefill energy falling by 52% on HotpotQA, 67% on LoCoMo and 77% on SkillsBench, with time to first token down 32 to 81%, depending on the workload. These are the authors' numbers on one phone. Measure on yours.</p>
<p><strong>The limit.</strong> You own the model, the packaging and the update path. The built-in tools handle these for you. ExecuTorch does not.</p>
<p><strong>Skip it when</strong> the built-in model is good enough. A custom model adds an app-size cost and a maintenance cost that the built-in options do not.</p>
<h2>7. Google's <code>genai-agent</code> library (alpha)</h2>
<p><strong>What it is.</strong> Google's agent runner for Android, built around <code>AgentRunner</code>. It lets the model call tools you define, including Android AppFunctions.</p>
<p><strong>The limits.</strong> Google says the library "is not subject to any SLA or deprecation policy." It needs Android 14 and enrollment in the AICore Developer Preview. It can break compatibility between versions. The runner stops after <code>maxTurns</code>, which defaults to 10, so a looping tool call cannot run forever.</p>
<p><strong>Skip it when</strong> you ship to production users. Treat it as a preview for prototypes until Google marks it stable.</p>
<h2>Bonus: the cloud fallback</h2>
<p>Every list like this ends with the same question: what happens when the phone cannot answer? The cloud model is the answer for most apps, and it has its own costs: the text leaves the device, each reply has a price, and the feature needs a network.</p>
<p>The routing policy matters more than the vendor. A short, testable policy is in <a href="https://sairamkotha.hashnode.dev/hybrid-ai-routing-on-device-first-cloud-fallback">Part 5: Hybrid AI Routing</a>. It sends personal input to the phone when the phone can serve it, and never silently sends it to the cloud because the phone is busy.</p>
<h2>Which tool to start with</h2>
<ul>
<li><strong>iPhone-only, short tasks:</strong> start with entry 1 and read <code>contextSize</code> at runtime.</li>
<li><strong>Android, short tasks:</strong> start with entry 2 and handle all four model states.</li>
<li><strong>Both platforms, one feature:</strong> use entry 3 for the routing, or write the policy yourself if you need a fallback the SDK does not offer.</li>
<li><strong>Your own model:</strong> entry 6, and budget for the packaging and updates.</li>
</ul>
<p>Start small. Pick one feature, one tool, and the device list your users actually own. Add the next tool only after the first one works on real phones.</p>
<h2>Sources</h2>
<ul>
<li>Apple, <code>GenerationError.exceededContextWindowSize</code>: the 4,096-token window and what counts toward it.</li>
<li>Apple, <code>SystemLanguageModel.contextSize</code> and <code>tokenCount(for:)</code>.</li>
<li>Google, ML Kit GenAI Prompt API, Android: model states, <code>BUSY</code> and quota errors.</li>
<li>Google, Gemini Nano on Android: AICore, offline use, memory budget statements.</li>
<li>Firebase AI Logic hybrid configuration options, Android: the four inference modes and their experimental status.</li>
<li>Apple Private Cloud Compute documentation: iOS 27 requirement and entitlement.</li>
<li>The 2026 KV-cache paper cited in entry 6: results are the authors', on one device.</li>
</ul>
<p>I did not run these tools on a device for this list. Claims come from the vendor documentation linked above. Where the docs disagree or stay silent, the entry says so.</p>
<hr />
<p>#OnDeviceAI #iOS #Android #MobileAI #AppleFoundationModels #GeminiNano</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[Hybrid AI Routing: Try the Phone First, Fall Back to the Cloud Without Surprising the User]]></title><description><![CDATA[Firebase hybrid modes, a routing policy you can test without a phone, timeouts, and logs that never store prompts.]]></description><link>https://sairamkotha.hashnode.dev/hybrid-ai-routing-on-device-first-cloud-fallback</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/hybrid-ai-routing-on-device-first-cloud-fallback</guid><category><![CDATA[on-device ai]]></category><category><![CDATA[Firebase ai logic]]></category><category><![CDATA[gemini-nano]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:44:27 GMT</pubDate><content:encoded><![CDATA[<p>Part 5 of 10 in the series on using on-device AI in your app. <a href="https://www.linkedin.com/pulse/your-apps-ai-can-run-three-places-each-one-sends-data-sai-ram-kotha-a1gbc/">Part 1</a> covered where the model runs. <a href="https://www.linkedin.com/pulse/same-task-three-models-your-first-call-apples-googles-sai-ram-kotha-vrxxc/">Part 2</a> made the first call on each. <a href="https://sairamkotha.hashnode.dev/what-the-phone-s-built-in-ai-can-and-cannot-do-limits-typed-output-tools-and-images">Part 3</a> listed what the default models can do. <a href="https://sairamkotha.hashnode.dev/decide-per-feature-where-on-device-ai-runs">Part 4</a> gave you a per-feature decision table. This part covers the case that table left open: a feature that should try the phone first, and use the cloud only when the phone cannot answer.</p>
<p>By the end you will be able to do four things. Write a routing rule as code that you can test without a phone. Pick the right fallback behaviour for each kind of input. Decide how long to wait before falling back. Log which path answered, without logging what the user typed.</p>
<p>I did not run these models for this article. The Android code follows the Firebase AI Logic documentation I read for it. The Apple section uses names I could not confirm from Apple's pages, and it says so where they appear. The custom routing policy is my own sketch. It is plain code with no SDK calls, so you can run its tests on a laptop.</p>
<h2>The problem in one scenario</h2>
<p>A user opens a notes app and asks it to summarize a long meeting transcript. Three things can happen before the answer appears.</p>
<ol>
<li>The phone has the model, and the model answers in two seconds.</li>
<li>The phone does not have the model. The OS is too old, or the user turned the feature off, or the model is still downloading. The app has to decide what to do now.</li>
<li>The phone has the model, but it takes forty seconds because the device is hot and throttling. The user is staring at a spinner.</li>
</ol>
<p>Hybrid routing is the set of rules that handles cases 2 and 3. Case 1 needs no routing at all. Most of the difficulty is in the cases where the phone is not ready, and in deciding how long "not ready yet" should last.</p>
<p>Two words carry the design, so define them before going on.</p>
<ul>
<li><strong>Capability</strong> is what the device can run at all. A phone without the model, or an OS without the API, has no capability. This does not change while the app is open.</li>
<li><strong>Availability</strong> is whether the model can answer right now. A model that is downloading, or a feature the user turned off, is capable but not available. This can change in seconds.</li>
</ul>
<p>Most fallback bugs come from treating these two as one. An app that falls back on "not capable" and an app that falls back on "not available" should behave differently for a personal input, because the second case often clears up on its own in a minute.</p>
<h2>Two ways to route</h2>
<p><strong>Built-in modes.</strong> Firebase AI Logic's hybrid API lets you choose a mode when you create the model. The mode decides the path and the fallback. It is the least code, and the behaviour is fixed by the SDK.</p>
<p><strong>Custom routing.</strong> Your code decides the path for each request, using whatever signals you have. Firebase's hybrid page lists three factors you can use: network latency, device health such as battery level and processor load, and how complex the query is. The page gives no code for this. You write it, and you own its tests.</p>
<p>Use built-in modes when the rule is simple and the SDK's rule matches yours. Use custom routing when the rule depends on your own policy, such as personal data, a time budget per feature, or a battery floor.</p>
<h2>Built-in modes, with the code</h2>
<p>Firebase AI Logic gives four inference modes:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Uses the phone when</th>
<th>When the phone cannot answer</th>
</tr>
</thead>
<tbody><tr>
<td><code>PREFER_ON_DEVICE</code></td>
<td>The phone model is available and supports the request</td>
<td>Falls back to the cloud model</td>
</tr>
<tr>
<td><code>ONLY_ON_DEVICE</code></td>
<td>The phone model is available and supports the request</td>
<td>Throws an exception</td>
</tr>
<tr>
<td><code>PREFER_IN_CLOUD</code></td>
<td>The device is online and the cloud model is available</td>
<td>Falls back to the phone model only when offline</td>
</tr>
<tr>
<td><code>ONLY_IN_CLOUD</code></td>
<td>Never. Always the cloud model</td>
<td>Throws an exception</td>
</tr>
</tbody></table>
<p>Here is <code>PREFER_ON_DEVICE</code> in Kotlin, adapted from the Firebase hybrid documentation:</p>
<pre><code class="language-kotlin">val model = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel(
    modelName = "your-cloud-model-name", // pick the name from the Firebase docs
    onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE)
)

val response = model.generateContent("Summarize this transcript: ...")
println(response.text)
</code></pre>
<p>Three facts in the documentation shape how you use this.</p>
<p><strong>There is no default cloud model.</strong> The SDK does not choose one for you. The modes that can reach the cloud need you to name the model when you create the instance. If you leave it out, you have a phone-only setup with a fallback that has nowhere to go.</p>
<p><strong>The response says who answered.</strong> The <code>inferenceSource</code> value is <code>ON_DEVICE</code> or <code>IN_CLOUD</code>. Check the exact property name on the response type in the SDK version you use. Log this value for every reply. It is the record you will use later to count how often each path answered.</p>
<p><strong>The mode belongs to the instance.</strong> It stays fixed for the life of the <code>GenerativeModel</code> object. To change it, create a new object. This matters when a feature flag changes the mode while the screen is open. Plan for the new mode to take effect on the next screen, not in the middle of a request.</p>
<p>The Firebase page also marks the hybrid SDK as experimental. It has no SLA or deprecation policy, and it can change in incompatible ways. The on-device model API it calls, the ML Kit Prompt API, is in beta. Put both in the release plan, and keep the routing rule in your own code so you can change it without waiting for the SDK.</p>
<h2>Custom routing: a policy you can test without a phone</h2>
<p>Here is the policy I would start from. It is plain Kotlin with no SDK calls. You feed it the facts about the request and the device, and it returns a path and a reason. Because it has no dependencies, you can test every branch on a laptop.</p>
<pre><code class="language-kotlin">enum class Path { ON_DEVICE, CLOUD, NONE }

data class Facts(
    val personalInput: Boolean,      // from the feature's row in your Part 4 table
    val onDeviceCapable: Boolean,    // device and OS can run the model at all
    val onDeviceAvailable: Boolean,  // model is ready right now
    val online: Boolean,
    val batteryPercent: Int,
    val thermalCritical: Boolean,    // OS thermal state at its highest level
)

data class Decision(val path: Path, val reason: String)

fun route(f: Facts, cloudAllowedForFeature: Boolean): Decision = when {
    // 1. Privacy first. Personal input never goes to the cloud silently.
    f.personalInput &amp;&amp; !f.onDeviceAvailable -&gt;
        Decision(Path.NONE, "personal input, phone not ready")

    // 2. Phone is ready and the device is healthy enough to use it.
    f.onDeviceAvailable &amp;&amp; !f.thermalCritical &amp;&amp; f.batteryPercent &gt;= 15 -&gt;
        Decision(Path.ON_DEVICE, "phone ready")

    // 3. Phone is ready but unhealthy. Cloud only for public input, if the feature allows it.
    f.onDeviceAvailable &amp;&amp; !f.personalInput &amp;&amp; cloudAllowedForFeature &amp;&amp; f.online -&gt;
        Decision(Path.CLOUD, "phone unhealthy, public input, feature allows cloud")

    // 4. Phone is not ready. Cloud only for public input, if the feature allows it and we can reach it.
    !f.onDeviceAvailable &amp;&amp; !f.personalInput &amp;&amp; cloudAllowedForFeature &amp;&amp; f.online -&gt;
        Decision(Path.CLOUD, "phone not ready, public input, feature allows cloud")

    // 5. Nothing fits. Say so; do not guess.
    else -&gt; Decision(Path.NONE, "no permitted path")
}
</code></pre>
<p>The order matters more than the thresholds. Privacy is checked first, so no later rule can send personal input to the cloud. Health is checked before the cloud, so a hot phone with a working model does not trigger a cloud call that the user did not ask for. The thresholds (15 percent battery, a critical thermal state) are starting points. You set them from your own field data, as Article 31 explains for thermal state.</p>
<p>Now test the policy. These are plain assertions, no framework needed:</p>
<pre><code class="language-kotlin">fun main() {
    val ready = Facts(personalInput = false, onDeviceCapable = true,
        onDeviceAvailable = true, online = true, batteryPercent = 80, thermalCritical = false)

    // Public input, phone ready: phone answers.
    check(route(ready, cloudAllowedForFeature = true).path == Path.ON_DEVICE)

    // Personal input, phone not ready, cloud allowed: still no cloud.
    val personalNotReady = ready.copy(personalInput = true, onDeviceAvailable = false)
    check(route(personalNotReady, cloudAllowedForFeature = true).path == Path.NONE)

    // Public input, phone not ready, cloud allowed, online: cloud.
    val publicNotReady = ready.copy(onDeviceAvailable = false)
    check(route(publicNotReady, cloudAllowedForFeature = true).path == Path.CLOUD)

    // Public input, phone not ready, cloud NOT allowed: no path.
    check(route(publicNotReady, cloudAllowedForFeature = false).path == Path.NONE)

    // Public input, phone not ready, offline: no path, not a silent failure.
    val offline = publicNotReady.copy(online = false)
    check(route(offline, cloudAllowedForFeature = true).path == Path.NONE)

    // Personal input, phone ready but hot, cloud allowed: still no cloud.
    val personalHot = ready.copy(personalInput = true, thermalCritical = true)
    check(route(personalHot, cloudAllowedForFeature = true).path == Path.NONE)

    println("routing policy: all checks passed")
}
</code></pre>
<p>Each check protects one rule. If someone later reorders the <code>when</code> branches, the first check or the second check will fail. That is the reason to write policy as code rather than as a diagram on a wiki page.</p>
<h2>Timeouts: how long to wait before you fall back</h2>
<p>A fallback that waits forever is a broken feature. A fallback that gives up too fast throws away a good on-device answer. The right wait depends on the feature.</p>
<p>Here is a way to set it:</p>
<ol>
<li>Take the feature's latency budget from your Part 4 table. A keyboard suggestion has almost no budget, so it never waits.</li>
<li>Measure the on-device time to first token on your oldest supported phone, using the test from Article 35.</li>
<li>Set the wait to the budget minus the cloud round trip you measured, not the on-device time.</li>
<li>Write the number down with its source. "Two seconds, measured on the oldest phone, March run" is a number you can defend. "Two seconds" alone is a guess.</li>
</ol>
<p>Remember that cloud calls also have a cost in time that is easy to forget: the request has to be sent, the model has to start, and the answer has to come back. If you measure only the model time, your fallback will feel slower than you planned.</p>
<h2>Streaming: do not switch in the middle of an answer</h2>
<p>Many on-device and cloud calls stream text as it is generated. Streaming creates a case the policy above does not cover: the phone starts answering, then something goes wrong halfway through.</p>
<p>The safe rule is simple. Decide the path before the first token is shown, and never switch paths in the middle of an answer. If the phone fails after it has shown half a sentence, you have two options, and you must pick one in advance:</p>
<ul>
<li><strong>Finish the answer on the phone, and show an error if it cannot finish.</strong> The user's data stays on the device.</li>
<li><strong>Discard the partial answer and restart on the cloud, with a visible notice.</strong> This is only allowed for features where the cloud path is permitted. The notice must say the answer restarted and where it came from.</li>
</ul>
<p>Mixing the two in the same answer is the bug to avoid. The user sees one half in one voice and the other half in another, and cannot tell which part came from where.</p>
<h2>The fallback message: say what happened</h2>
<p>The user should never have to guess why the answer looks different. Write the message for each path, and keep it short.</p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>What the user sees</th>
</tr>
</thead>
<tbody><tr>
<td>Answered on the phone</td>
<td>Nothing extra. The phone is the default and needs no label.</td>
</tr>
<tr>
<td>Answered in the cloud</td>
<td>"Answered in the cloud because this phone's model is not ready."</td>
</tr>
<tr>
<td>Phone not ready, personal input</td>
<td>"This needs the on-device model, which is not ready yet. Try again in a few minutes."</td>
</tr>
<tr>
<td>Offline, public input</td>
<td>"You are offline. Connect to get this answer from the cloud."</td>
</tr>
</tbody></table>
<p>Two rules make these messages honest. First, never say "for better results" in place of the real reason. It hides the change in where the data went, which is the change the user cares about most. Second, show the label next to the answer, not in a settings page. A label the user has to go looking for does not tell them anything at the moment it matters.</p>
<p>Use <code>inferenceSource</code> to decide whether to show the cloud label. If it says <code>IN_CLOUD</code>, show the label. If it says <code>ON_DEVICE</code>, show nothing. The default path stays quiet, and the exception is always visible.</p>
<h2>Apple: same session, different backend</h2>
<p>Apple's approach is different. Apple's WWDC26 session on the Foundation Models framework introduces a <code>LanguageModel</code> protocol. It says that <code>SystemLanguageModel</code> and <code>PrivateCloudComputeLanguageModel</code> already conform to it. The goal is that session code can stay the same while the model behind it changes.</p>
<p>I could not confirm the protocol's members, its conforming types beyond those two, or its availability reasons from Apple's documentation pages. The pages I fetched returned only their titles. The sketch below uses names from Article 34, which covered the availability check. Check every name against Apple's current documentation before you write production code:</p>
<pre><code class="language-swift">import FoundationModels

func answer(_ prompt: String) async throws -&gt; String {
    switch SystemLanguageModel.default.availability {
    case .available:
        let session = LanguageModelSession()
        return try await session.respond(to: prompt).content
    case .unavailable(let reason):
        // Read the reason and pick the message from the table above.
        // Route to a cloud model only if this feature allows it.
        throw RoutingError.phoneNotReady(String(describing: reason))
    }
}

enum RoutingError: Error {
    case phoneNotReady(String)
}
</code></pre>
<p>The point of the sketch is the shape, not the names. The availability check happens before the session is created. The fallback decision is made in one function you can test, the same way the Kotlin policy is tested. The session code does not decide where the data goes.</p>
<h2>Logging: record the path, never the prompt</h2>
<p>Every reply should produce one record. It is how you learn whether the device path works for each feature, and it must not contain user text. Here is a record that is safe to store:</p>
<pre><code class="language-json">{
  "feature": "meeting_summary",
  "inference_source": "IN_CLOUD",
  "route_reason": "phone not ready, feature allows cloud",
  "personal_input": false,
  "time_to_first_token_ms": 640,
  "app_version": "2.4.1"
}
</code></pre>
<p>Four rules for the log:</p>
<ul>
<li><strong>No prompt, no answer.</strong> Store the reason and the source, not the text.</li>
<li><strong>Count fallbacks per feature.</strong> If a feature falls back on 40 percent of replies, the phone path is not working for that feature. The fix is usually the model choice or the readiness check, not a bigger cloud budget.</li>
<li><strong>Check the disclosure.</strong> If any feature can send personal input to the cloud, the store disclosures must say so. Article 40 covers how to do that for Google Play and the Apple privacy label.</li>
<li><strong>Keep the record short.</strong> Each field should answer a question you will actually ask later.</li>
</ul>
<p>Article 41 uses these records to show which model answered each request in production.</p>
<h2>Common mistakes, and the fix for each</h2>
<p><strong>Falling back on any error.</strong> A network timeout, a guardrail refusal and a busy model are different problems. Falling back to the cloud on all three sends personal data for a problem the phone could have solved by waiting. Fix: map each error to a path in the policy, as the Kotlin sketch does.</p>
<p><strong>Treating "not capable" and "not available" as one.</strong> A model that is still downloading will often be ready in minutes. A device that cannot run the model at all will never be ready. Fix: keep the two facts separate in the policy, and give "not available" a short retry window before you fall back.</p>
<p><strong>Changing the mode in a live screen.</strong> The mode belongs to the instance, so a flag change mid-screen has no effect until the instance is replaced. Users then see the old behaviour, and the bug report says the flag is broken. Fix: apply mode changes on the next screen.</p>
<p><strong>No test for the switch.</strong> Teams test that the cloud answer is good, and never test that the switch happens. Fix: run the cases in the next section on real devices, and keep the policy tests in CI.</p>
<p><strong>Showing the answer without the label.</strong> The user cannot tell that the data left the phone. Fix: the label is part of the answer component, not an optional extra.</p>
<h2>Test the switch on real devices</h2>
<p>Run these cases on the oldest phone you support, not only your own. Record the expected path, the actual path, and the message the user saw.</p>
<ol>
<li><strong>Model off in settings.</strong> Expect the policy to route by the feature's rule. For personal input, expect no cloud call.</li>
<li><strong>Model downloading.</strong> Start the request during the download. Expect a wait up to your timeout, then the rule's path.</li>
<li><strong>Offline.</strong> Turn off the network. Expect <code>PREFER_IN_CLOUD</code> to use the phone, and <code>ONLY_IN_CLOUD</code> to show an error the user can act on.</li>
<li><strong>Mode change.</strong> Change the flag while a screen is open. Expect the next screen to use the new mode, and the open screen to keep the old one.</li>
<li><strong>Hot device.</strong> Run a long request until the device throttles. Expect the policy to route by the thermal rule, and no mid-answer switch.</li>
<li><strong>Log check.</strong> Run ten requests. Expect ten records with the right source, and no prompt text in any of them.</li>
</ol>
<p>Count the failures by kind. A wrong path is a different bug from a wrong answer, and the fix is in different code.</p>
<h2>Exercises</h2>
<ol>
<li>Add a new fact to the <code>Facts</code> class: "the user has disabled cloud features in settings." Update the policy and add one test that proves a cloud call never happens after that setting is on, even for public input.</li>
<li>Pick one feature from your own app. Write its row in the Part 4 table, then write its routing policy as a Kotlin function with three tests.</li>
<li>Measure the time to first token for one request on your oldest phone, and compute the timeout from the method above. Write down the number and its source.</li>
</ol>
<h2>What this series does next</h2>
<p>Part 6 covers measurement. You will run one eval set on every model and every path, and put time to first token, total time and cost per task on one scale. It uses the test table from Article 35 and the feature table from Article 36.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<p><strong>Google and Firebase (fetched for this article)</strong></p>
<ul>
<li><a href="https://developer.android.com/ai/hybrid">Hybrid on-device and cloud inference, Android</a>. The fallback statement, the Firebase hybrid option, and the custom-routing factors: network latency, device health, query complexity.</li>
<li><a href="https://firebase.google.com/docs/ai-logic/hybrid/android/configuration-options">Firebase AI Logic hybrid configuration options, Android</a>. The four inference modes, the absence of a default cloud model, <code>inferenceSource</code>, per-instance configuration, the experimental status, and the on-device model options.</li>
</ul>
<p><strong>Apple (partly verified)</strong></p>
<ul>
<li><a href="https://developer.apple.com/documentation/foundationmodels">Foundation Models, Apple Developer Documentation</a>. Fetched, but the page returned only its title. Not used for specific claims.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/languagemodel">LanguageModel, Apple Developer Documentation</a>. Fetched, title only. Not used for specific claims.</li>
<li>WWDC26 session 241, "What's new in the Foundation Models framework," as summarized by a secondary index: the <code>LanguageModel</code> protocol and the conforming system models. <a href="https://nonstrict.eu/wwdcindex/wwdc2026/241/">Session index</a>. Verify against Apple's video before you rely on it.</li>
<li>WWDC26 session 339, "Bring an LLM provider to the Foundation Models framework," as summarized by the same index: the provider role and the planned packages. <a href="https://nonstrict.eu/wwdcindex/wwdc2026/339/">Session index</a>.</li>
</ul>
<p><strong>Earlier articles in this series</strong></p>
<ul>
<li>Article 31, on thermal state and the energy study. Used for the thermal threshold idea.</li>
<li>Article 34, on the availability check. Used for the Swift sketch's structure.</li>
<li>Article 35, on the test table and the 30-input method.</li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>Apple's <code>LanguageModel</code> protocol members, its conforming types beyond the two named, and its availability reasons. The Apple pages I fetched returned only titles.</li>
<li>Whether Anthropic's and Google's Swift packages for Apple's framework have shipped. Session 339 describes them as coming soon, and I found no release note.</li>
<li>Whether Firebase's <code>PREFER_ON_DEVICE</code> logs an error when it falls back. One Firebase page says it logs an error; the configuration page does not mention logging.</li>
<li>Whether Firebase's hybrid spend cap is configurable in the product. One page says spend caps can be set for cloud-routed requests; the configuration page does not mention them.</li>
<li>The exact name of the <code>inferenceSource</code> property in each SDK version. The Kotlin sketch uses the documented concept, not a verified property path.</li>
<li>The routing thresholds (15 percent battery, critical thermal state). They are starting points, not measured values.</li>
<li>Any measured latency, battery, cost or fallback rate. This article sets the design. Your logs decide whether the design holds.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Decide Per Feature, Not Per App: Where Each AI Call Should Run on the Phone]]></title><description><![CDATA[How to score each AI feature on privacy, latency, quality, offline and cost, then choose on-device, cloud or split, with a worked table.]]></description><link>https://sairamkotha.hashnode.dev/decide-per-feature-where-on-device-ai-runs</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/decide-per-feature-where-on-device-ai-runs</guid><category><![CDATA[on-device ai]]></category><category><![CDATA[AI Architecture]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><category><![CDATA[Mobile Ai]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:36:29 GMT</pubDate><content:encoded><![CDATA[<p>Part 4 of 10 in the series on using on-device AI in your app. <a href="https://www.linkedin.com/pulse/your-apps-ai-can-run-three-places-each-one-sends-data-sai-ram-kotha-a1gbc/">Part 1</a> covered where the model runs. <a href="https://www.linkedin.com/pulse/same-task-three-models-your-first-call-apples-googles-sai-ram-kotha-vrxxc/">Part 2</a> made the first call on each. <a href="https://sairamkotha.hashnode.dev/what-the-phone-s-built-in-ai-can-and-cannot-do-limits-typed-output-tools-and-images">Part 3</a> listed what the default models can and cannot do. This part turns that list into a decision, one feature at a time.</p>
<p>Most teams make the choice once: "we are an on-device app" or "we use the cloud." That choice is wrong for most apps. A single app usually has a keyboard-speed feature, a summary feature and a long-reasoning feature. Each one has a different budget, and each one should run where that budget fits.</p>
<p>I did not run anything for this article. The examples come from the earlier parts of this series, which cite Apple's and Google's pages, and the matrix is my own framework. The only figures are ones the earlier parts state, with their sources. The latency descriptions are rough ranges to plan with, not measurements.</p>
<h2>Five things to score for each feature</h2>
<p>Score each feature on five axes before you pick a location.</p>
<table>
<thead>
<tr>
<th>Axis</th>
<th>The question</th>
<th>What it looks like in practice</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Privacy</strong></td>
<td>Can this input leave the phone?</td>
<td>A user's message history is sensitive. A product catalogue lookup is not.</td>
</tr>
<tr>
<td><strong>Latency</strong></td>
<td>How long can the user wait?</td>
<td>A keyboard suggestion has a few hundred milliseconds. A research answer can take seconds.</td>
</tr>
<tr>
<td><strong>Quality</strong></td>
<td>Does the task need a large model?</td>
<td>Summarizing three messages fits a small model. Reviewing a pull request does not.</td>
</tr>
<tr>
<td><strong>Offline</strong></td>
<td>Must it work with no network?</td>
<td>A note tagger on a plane. A chat with a cloud model on a plane is not possible.</td>
</tr>
<tr>
<td><strong>Cost per task</strong></td>
<td>Who pays, and in what?</td>
<td>Cloud is billed per token. On-device is paid in battery and heat, which the user feels.</td>
</tr>
</tbody></table>
<p>These five are not equally important for every feature. A medical note tagger weights privacy and offline. A writing assistant weights quality and latency. Write the weights down before you score, or the scoring will just confirm what the team already wanted.</p>
<h3>How to score, with a worked example</h3>
<p>Use a 1 to 5 scale for each axis. Define what the numbers mean before you score, so two people reading the sheet mean the same thing:</p>
<ul>
<li><strong>Privacy:</strong> 5 means the input is personal or regulated and must not leave the phone. 1 means the input is public.</li>
<li><strong>Latency:</strong> 5 means the answer must appear within a fraction of a second. 1 means the user will wait a few seconds without noticing.</li>
<li><strong>Quality:</strong> 5 means the task needs a large model to be right. 1 means a small model does it well.</li>
<li><strong>Offline:</strong> 5 means the feature must work with no network. 1 means it can wait for one.</li>
<li><strong>Cost sensitivity:</strong> 5 means the cloud bill or the battery use would be a real problem at your scale. 1 means it does not matter.</li>
</ul>
<p>Now score the three features from this article's examples. Higher numbers mean the feature needs that property more:</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Privacy</th>
<th>Latency</th>
<th>Quality</th>
<th>Offline</th>
<th>Cost sensitivity</th>
<th>Where it runs</th>
</tr>
</thead>
<tbody><tr>
<td>Keyboard suggestion</td>
<td>5</td>
<td>5</td>
<td>2</td>
<td>5</td>
<td>4</td>
<td>On the phone</td>
</tr>
<tr>
<td>Notification summary</td>
<td>5</td>
<td>4</td>
<td>2</td>
<td>3</td>
<td>3</td>
<td>On the phone, with fallback</td>
</tr>
<tr>
<td>Trip planner</td>
<td>1</td>
<td>2</td>
<td>5</td>
<td>1</td>
<td>2</td>
<td>Cloud</td>
</tr>
<tr>
<td>Booking summary</td>
<td>5</td>
<td>2</td>
<td>3</td>
<td>3</td>
<td>2</td>
<td>Split</td>
</tr>
</tbody></table>
<p>Read the table with the rule from later in this article. Any feature with privacy 5 starts on the phone. Any feature with quality 5 and offline 1 goes to the cloud. The rest is judgement, and the table makes that judgement visible for review. If someone disagrees with a cell, the argument becomes about that cell, which is far more useful than an argument about the whole feature.</p>
<h2>Two examples from this series, scored</h2>
<p><strong>The keyboard suggestion (Article 29).</strong> Apple said at WWDC 2023, as reported in press coverage, that the iOS keyboard runs a transformer language model on the device and runs it on every key press. On Gboard, Google's research describes next-word models trained with federated learning and differential privacy. Because the model runs on the phone, its cost lands in battery, not in a cloud bill. Score it: privacy high, latency tight, quality moderate, offline required, cost paid in energy. Location: on-device, always. A cloud call on every keystroke would fail the latency budget and would be wrong for privacy.</p>
<p><strong>The notification summary (Article 28).</strong> A notification must fit a 4 KB payload, and the system may give your extension a short time budget before it stops. The summary is small, and the input is personal. Score it: privacy high, latency tight, quality moderate, offline useful. Location: on-device if the device can run the model and the time budget holds, with a fallback that shows the original text. One limit matters most here. Article 28 found that Apple's developer documentation does not say a third-party extension can call Apple's own summarizer. If your feature needs the model in that path, plan on running your own model under your own budget, and do not assume Apple's summary is available to you.</p>
<p>Both of these lean on-device for the same reason. The input is short, the output is short, and the user expects the answer to arrive with the screen, not after a network trip.</p>
<h2>One feature that leans cloud</h2>
<p>Take a feature that asks the model to plan a multi-step trip, compare four insurance policies, or write and check a function. Score it.</p>
<ul>
<li><strong>Quality</strong> needs a large model. Apple's guidance says the on-device model "may not be suitable" for basic math, code generation and logical reasoning. Article 35 quotes that list.</li>
<li><strong>Context</strong> is the constraint. The on-device window is 4,096 tokens per session, and the policy text plus instructions plus the answer can run out of room.</li>
<li><strong>Offline</strong> is probably not required, because the user has the network to plan a trip.</li>
</ul>
<p>Location: cloud, with a clear message when the network is off. Article 35 shows why the on-device model is the wrong tool here: you would be asking a 4K model to do work it says it is not suited for.</p>
<h2>The feature that needs both</h2>
<p>Most real features are split. Take a travel app with a "summarize my bookings" feature.</p>
<ol>
<li>The booking details stay on the phone. They are personal, and the summary input is small.</li>
<li>The model that writes the summary runs on the device when it is available.</li>
<li>The "find me a cheaper flight" step calls a cloud model, because it needs live prices and a larger model.</li>
<li>The search tool sends only the search query off the phone, not the booking history. Article 35 notes that a tool that calls your server sends data off the phone, so the tool's arguments are the boundary to design.</li>
</ol>
<p>One feature, two locations, and the boundary is written down in the code. That is the normal case, not the exception.</p>
<p>Here is what crosses the boundary in that design, step by step:</p>
<ul>
<li>The phone reads the bookings and builds the summary prompt. Nothing leaves the phone yet.</li>
<li>The phone runs the summary on the on-device model. Nothing leaves the phone.</li>
<li>The user taps "find a cheaper flight." The app sends the origin, destination, dates and a price-search query to a cloud model. The booking history, names and confirmation codes stay on the phone.</li>
<li>The cloud model returns options. The phone compares them with the user's saved preferences, which stay on the phone.</li>
</ul>
<p>The data that crosses is small and listed in advance. If a future change adds a field to that request, the review should ask why it is needed. That is the practical meaning of "the boundary is written down."</p>
<h2>The rule for splitting a feature</h2>
<p>Use this order. Stop at the first step that answers the question.</p>
<ol>
<li><strong>Does the input contain personal data the user would not expect to leave the phone?</strong> If yes, start on-device. Move a piece to the cloud only when a specific step needs a capability the device lacks, and send only that piece.</li>
<li><strong>Must the feature work offline?</strong> If yes, it must run on the device, and the cloud path is an enhancement, not the default.</li>
<li><strong>Does the device model fit the task?</strong> Check the window, the output length and the task list from Article 35. If the task is on Apple's "not suitable" list, send it to the cloud.</li>
<li><strong>Is the latency budget smaller than a cloud round trip?</strong> If yes, keep the feature on the device. A keyboard suggestion is the example: a network call would arrive after the user has typed the next word.</li>
<li><strong>Otherwise, pick by cost per task.</strong> Cloud is per token and easy to meter. On-device is per battery percent and hard to meter. If the cloud cost per task is small and the quality gain is real, cloud wins.</li>
</ol>
<p>Applied to the examples above: the keyboard passes step 4 and stays on the device. The trip planner fails step 3 and goes to the cloud. The booking summary is split because step 1 and step 3 point in different directions.</p>
<h2>Test one size down before you pick the big model</h2>
<p>A 2026 energy study cited in Article 31 found that model architecture mattered more than the quantization scheme, and that mid-sized models like Qwen2.5-3B sat at a balance point between quality and energy. That is one paper, measured on one flagship Android phone, so treat it as a reason to test, not a rule.</p>
<p>Test a smaller model where you have one. On Android, Gemini Nano comes in versions, so you can compare the version you planned against an older or smaller one on your 30 inputs from Article 35. On iOS, the system model is a single model with no size choice, so the smaller comparison is an external model, or you skip the step and rely on the tests from Article 35. If the smaller option passes, ship it. The battery difference is the cost you are avoiding, and the user feels it every day.</p>
<h2>What to write down before you build</h2>
<p>For each feature, write one line in your design doc:</p>
<ul>
<li><strong>Feature:</strong> name.</li>
<li><strong>Location:</strong> on-device, cloud, or split. If split, name the boundary and what crosses it.</li>
<li><strong>Why:</strong> the first rule in the list above that decided it.</li>
<li><strong>Fallback:</strong> what the user sees when the chosen location is unavailable.</li>
<li><strong>Measure:</strong> the number that would prove the choice wrong, such as first-token time on the oldest supported phone, or the share of answers that fail the Article 35 test.</li>
</ul>
<p>A table with one row per feature is enough. Parts 5 and 6 use that table: Part 5 for the fallback path, and Part 6 for measuring cost and latency on each location.</p>
<h2>Common mistakes</h2>
<p><strong>Deciding the whole app once.</strong> A team picks "cloud" because the big feature needs it, and sends the keyboard through a network call. Fix: score features one at a time, and let the table show the exceptions.</p>
<p><strong>Scoring on what the team wants.</strong> If every feature scores 5 on quality, the scores tell you nothing. Fix: ask for the worst case the feature has to survive, and score that. A summary that must work on the oldest phone scores differently from the same summary on the newest.</p>
<p><strong>Ignoring the data in the request.</strong> Teams decide the location, then add fields to the cloud request later without review. Fix: list the fields that cross the boundary for each split feature, and treat a new field as a change to the decision.</p>
<p><strong>Forgetting the fallback.</strong> A feature is on the phone "unless the phone cannot answer," and nobody decides what happens then. Fix: the fallback column in the design doc is not optional. Part 5 covers how to build it.</p>
<h2>Exercises</h2>
<ol>
<li>Take one feature from your own app. Score it on the five axes using the 1 to 5 definitions above. Then find the cell you are least sure about, and write down what evidence would change it.</li>
<li>Write the list of fields that would cross the boundary for a split feature you have built or planned. Remove any field the cloud step does not need.</li>
<li>Pick one feature that you would put in the cloud today. Write the sentence that tells the user where their data went. If you cannot write it plainly, the feature is not ready for the cloud path.</li>
</ol>
<h2>What this series does next</h2>
<p>Part 5 covers hybrid routing: try on-device first, fall back to the cloud, and tell the user when the switch happens. It includes a routing policy you can test without a phone.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<p><strong>Earlier articles in this series (the facts are from these, and they cite the pages below)</strong></p>
<ul>
<li>Article 28, "Your phone summarizes notifications." The 4 KB payload limit, the extension time budget, and the open question about third-party access to Apple's summarizer.</li>
<li>Article 29, "Your keyboard predicts the next word." The model runs on each keystroke, Gboard's federated and differentially private training, and the energy-not-cloud point.</li>
<li>Article 31, "Thermal throttling and on-device AI." The architecture-over-quantization finding and the mid-sized model balance point, from one 2026 energy study on a flagship Android phone.</li>
<li>Article 35, "What the phone's built-in AI can and cannot do." The 4,096-token window, the "may not be suitable" list, and the note that tools which call your server send data off the phone.</li>
</ul>
<p><strong>Apple</strong></p>
<ul>
<li><a href="https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models">Generating content and performing tasks with Foundation Models</a>. The capability list and the "may not be suitable" list. Fetched for Article 35.</li>
</ul>
<p><strong>Google</strong></p>
<ul>
<li><a href="https://developers.google.com/ml-kit/genai">Overview of the ML Kit GenAI APIs</a>. Device support by Nano version. Cited in Article 35.</li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>Any measured latency, battery or quality number for the features above. This article sets the framework. Your measurements decide the locations.</li>
<li>Whether a third-party notification extension can call Apple's notification summarizer. Article 28 could not confirm it, and this article does not change that.</li>
<li>The exact energy cost of the keyboard model on a current iPhone. Apple's public statements do not give a per-keystroke figure.</li>
<li>The Gboard privacy budget values for each model. Article 29 quotes the range from Google's blog, and I did not re-check the individual models.</li>
<li>Which feature split Apple or Google recommends for a hybrid app. None of the pages cited here covers a hybrid design.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[What the Phone's Built-In AI Can and Cannot Do: Limits, Typed Output, Tools and Images]]></title><description><![CDATA[What Apple's and Google's on-device models can do, where they stop, and a 30-input test to run on your own phones.]]></description><link>https://sairamkotha.hashnode.dev/what-the-phone-s-built-in-ai-can-and-cannot-do-limits-typed-output-tools-and-images</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/what-the-phone-s-built-in-ai-can-and-cannot-do-limits-typed-output-tools-and-images</guid><category><![CDATA[on-device ai]]></category><category><![CDATA[Apple Foundation Models]]></category><category><![CDATA[gemini-nano]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Fri, 30 Oct 2026 03:19:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/37d5d78d-0257-4af5-972d-c9d6cb011233.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Part 3 of 10 in the series on using on-device AI in your app. <a href="https://sairamkotha.hashnode.dev/your-app-s-ai-can-run-in-three-places-each-one-sends-your-data-somewhere-else">Part 1</a> covered where the model runs. <a href="https://sairamkotha.hashnode.dev/same-task-three-models-your-first-call-to-apple-s-google-s-and-an-external-ai">Part 2</a> made the first call on each. This part covers what you can reasonably ask for, and where the vendors' own documentation says to stop.</p>
<p>I did not run these models for this article. Everything below comes from Apple's and Google's documentation, with the places they disagree or go silent marked. The last section gives you a test to run on your own devices.</p>
<h2>The window: one number, and one number you cannot find</h2>
<p><strong>Apple</strong> states the limit. The on-device model's context window is 4,096 tokens, and the count "includes instructions, prompts, and outputs for a session instance." Apple also gives you two calls to read it at runtime: <code>contextSize</code> and <code>tokenCount(for:)</code>. Use them, because Apple's WWDC26 sample prints a different number from a newer model.</p>
<p>One detail that surprises people: your type definitions count too. Apple tells you to keep <code>@Generable</code> property descriptions "as short as possible" because "long descriptions take up additional context size and can introduce latency." The same goes for tool descriptions.</p>
<p><strong>Google</strong> does not state a number on any page I fetched. Its pages say Gemini Nano has "token limits" and "device hardware limitations," and give advice instead: keep the prompt concise, keep the output short, and keep system instructions under about 150 words. Output length matters because, in Google's words, "LLM inference speeds are heavily dependent on the output length."</p>
<p>So the planning rule is the same on both: budget by measuring the prompt, the schema, the tool descriptions and the expected reply together, then leave room.</p>
<h2>Typed output: both platforms have it, with different catches</h2>
<p>Free text is the model's default. For anything your code has to read, ask for a typed value.</p>
<p><strong>Apple</strong> calls it guided generation. You mark a Swift type <code>@Generable</code> and the framework uses constrained sampling, which "prevents the model from producing malformed output." It works on structures, enumerations and nested types, and on basic types like <code>Bool</code>, <code>Int</code>, <code>Float</code>, <code>Double</code> and <code>Array</code>. Properties are generated in the order you declare them. For a fixed label set, an enum is the natural fit:</p>
<pre><code class="language-swift">@Generable
enum Sentiment {
    case positive, neutral, negative
}

let response = try await session.respond(to: review, generating: Sentiment.self)
let label: Sentiment = response.content
</code></pre>
<p>If you do not know the shape until runtime, Apple offers <code>DynamicGenerationSchema</code>, for example to limit the model to today's menu.</p>
<p><strong>Google</strong> has the same idea in Kotlin, with the same macro names but a heavier setup. You annotate a data class with <code>@Generable</code> and <code>@Guide</code>, add the KSP plugin and a separate schema-compiler dependency, and wrap your request with <code>generateTypedContentRequest</code>:</p>
<pre><code class="language-kotlin">@Generable
data class ReviewResult(
    @Guide(
        description = "Overall sentiment of the review",
        enumValues = ["positive", "neutral", "negative"]
    )
    val sentiment: String
)

val base = GenerateContentRequest.Builder(TextPart(prompt)).build()
val typed = generateTypedContentRequest(
    generateContentRequest = base,
    outputClass = ReviewResult::class
)
val result: ReviewResult? =
    generativeModel.generateContent(typed).candidates.firstOrNull()?.response
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>Apple</th>
<th>Google</th>
</tr>
</thead>
<tbody><tr>
<td>Mechanism</td>
<td>Constrained sampling</td>
<td>Typed request returning a parsed object</td>
</tr>
<tr>
<td>Languages</td>
<td>Swift</td>
<td>Kotlin only</td>
</tr>
<tr>
<td>Setup</td>
<td>Import the framework</td>
<td>KSP 2.3.6 or higher, plus <code>genai-schema-compiler:1.0.0-alpha1</code></td>
</tr>
<tr>
<td>Check before use</td>
<td>Model availability</td>
<td><code>isStructuredOutputFeatureAvailable()</code></td>
</tr>
<tr>
<td>Enum values</td>
<td>An <code>enum</code> type</td>
<td><code>enumValues</code> on a <code>String</code> guide</td>
</tr>
<tr>
<td>Nested types</td>
<td>Yes</td>
<td>Yes, but not circular ones</td>
</tr>
<tr>
<td>Known trap</td>
<td>A refusal on a typed request throws instead of returning text</td>
<td>ProGuard can break parsing unless you add keep rules</td>
</tr>
</tbody></table>
<p>Two lessons from the table. First, check feature availability separately from model availability on Android, because the typed API is its own feature. Second, the schema compiler is alpha. Pin its version.</p>
<h2>Tool calling: the model can ask your code for help</h2>
<p>A tool is a function the model may call to fetch data or take an action. It is how a 4,096-token model answers questions about data it cannot hold.</p>
<p><strong>Apple's</strong> <code>Tool</code> protocol takes a name, a description, a <code>@Generable</code> arguments type and a <code>call</code> method. The model decides whether a tool would help, and "can call a tool multiple times in parallel," so your tool code must handle concurrent calls. Tool output can be a string or any <code>@Generable</code> type. Apple also gives you a lever most teams miss: <code>toolCallingMode</code>, with three settings, allowed (the default), required, and disallowed. Required forces a lookup before an answer. If you set required, Apple warns you must define an exit, by throwing from the tool or changing the mode after the first call, "otherwise, the model continues to call the tool." I could not confirm from the pages I read which OS version added <code>toolCallingMode</code>, so check your deployment target.</p>
<p><strong>Google's</strong> version is the <code>genai-agent</code> library and its <code>AgentRunner</code>. It is alpha: Google says it "is not subject to any SLA or deprecation policy," needs Android 14 and enrollment in the AICore Developer Preview, and can break compatibility. Tools can be defined as Android AppFunctions. The runner stops after <code>maxTurns</code>, which defaults to 10, "to prevent runaway execution loops, conserve battery, and protect device responsiveness." Google's caution applies to both platforms: model predictions "might not always suggest the correct tool or arguments," and you are responsible for the actions taken.</p>
<p>One privacy point from Google's page is worth repeating. The reasoning loop runs on the device, but "individual tools implemented in your app can still make network calls." A tool that calls your server sends data off the phone, whatever the model's location.</p>
<p>There is also an open disagreement. Google's ML Kit page documents tool execution through <code>AgentRunner</code>. A July 2026 third-party post claims the separate ADK for Android <code>GenaiPrompt</code> path has no tool execution. I have not verified that claim, and it may be out of date. If you use the ADK path, test tool calls on a real device before you plan around them.</p>
<h2>Images: both platforms say yes, with conditions</h2>
<p><strong>Apple's</strong> on-device model accepts images alongside text, through an <code>Attachment</code> in the prompt. The framework scales and converts images for you. For classification, Apple's own sample combines an image with a <code>@Generable</code> enum and greedy sampling, so you get one of your labels and nothing else. Apple's advice is to try the on-device model first, and move to Private Cloud Compute only if the task needs more reasoning or context.</p>
<p><strong>Google's</strong> Prompt API accepts "text or multimodal (image and text) inputs." Its device lists are the catch: which phones support the Prompt API depends on the Gemini Nano version, so check the list before you promise an image feature in a market. For text pulled from a photo, Google suggests running its text-recognition API first and then passing the text to the model.</p>
<h2>What neither model should be asked to do</h2>
<p>Apple publishes the list. It says the on-device model is a fit for summarizing, extracting entities, understanding and refining text, classifying, tagging, and creative writing. It says the model "may not be suitable" for basic math, generating code, or logical reasoning, and gives examples of each.</p>
<p>Google's advice reads the same way in different words: prefer simple logic and a focused task. If a prompt needs "do X first, if the result is A, do M, otherwise N," split it into separate calls and chain them in your code. Google's reference mentions a thinking mode for harder tasks, which I did not fetch.</p>
<p>Google's prompt guidance has a few specifics worth copying:</p>
<ul>
<li>Use <code>##</code> delimiters between prompt sections. Google calls this "particularly critical for Gemini Nano."</li>
<li>Start with a temperature of 0.2 for extraction and translation.</li>
<li>Add examples, and use prefix caching when they make the prompt long.</li>
</ul>
<h2>A test you can run on your own phones</h2>
<p>The documentation tells you what is allowed. It does not tell you how often the model gets it right on your data. Run this before you choose:</p>
<ol>
<li><strong>Collect 30 real inputs</strong> for one feature, and write the correct answer for each by hand. Include the awkward ones: very short, very long, mixed language, profanity.</li>
<li><strong>Run each input</strong> through Apple's model, Gemini Nano and your external model, with the same instructions.</li>
<li><strong>For each run, record</strong> whether the output was valid and typed, whether it was correct, how long it took, how many tokens it used, and whether the call errored (guardrail, refusal, busy, unavailable).</li>
<li><strong>Count the failures by kind.</strong> A wrong answer, a refusal and an error need different fixes.</li>
<li><strong>Repeat on the oldest phone you support</strong>, not only your own.</li>
</ol>
<p>You will end up with a table of about 90 rows. It will tell you more than any vendor page, and it is the input Part 6 of this series uses to compare cost and latency.</p>
<h2>What this series does next</h2>
<p>Part 4 turns the table into a decision: which features go on the phone, which go to the cloud, and which need both.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<p><strong>Apple (fetched as Apple's documentation JSON)</strong></p>
<ul>
<li><a href="https://developer.apple.com/documentation/foundationmodels/generating-swift-data-structures-with-guided-generation">Generating Swift data structures with guided generation</a>. Constrained sampling, supported types, declaration order, short descriptions, dynamic schemas.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/expanding-generation-with-tool-calling">Expanding generation with tool calling</a>. The <code>Tool</code> protocol, parallel calls, <code>toolCallingMode</code>, the exit condition.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/analyzing-images-with-multimodal-prompting">Analyzing images with multimodal prompting</a>. <code>Attachment</code>, image types, the label-enum sample, "on-device first."</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models">Generating content and performing tasks with Foundation Models</a>. The capability table and the "may not be suitable" list.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/contextsize">SystemLanguageModel.contextSize</a> and <a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror/exceededcontextwindowsize(_:)">GenerationError.exceededContextWindowSize</a>. The 4,096 figure and what counts toward it.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/improving-the-safety-of-generative-model-output">Improving the safety of generative model output</a>. Refusal behaviour for typed requests.</li>
</ul>
<p><strong>Google (fetched)</strong></p>
<ul>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/references/structured-output.md">Structured output guide</a>. <code>@Generable</code>, <code>@Guide</code>, supported types, KSP, schema compiler version, ProGuard, <code>isStructuredOutputFeatureAvailable()</code>.</li>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/references/prompt-design.md">Prompt design guide</a>. Delimiters, temperature, short output, focused tasks.</li>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/references/system-instructions.md">System instructions guide</a>. The 150-word advice.</li>
<li><a href="https://developers.google.com/ml-kit/genai/agent/android">Build agentic workflows with Gemini Nano</a> and <a href="https://developers.google.com/ml-kit/genai/agent/android/get-started">Get started with agentic workflows</a>. Alpha status, <code>AgentRunner</code>, AppFunctions, <code>maxTurns</code>, Android 14 and Developer Preview, tools making network calls.</li>
<li><a href="https://developers.google.com/ml-kit/genai/prompt/android">Send custom requests with Gemini Nano direct model access</a>. Text and multimodal inputs, beta status.</li>
<li><a href="https://developers.google.com/ml-kit/genai">Overview of the ML Kit GenAI APIs</a>. Device lists by Nano version.</li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>Any measured accuracy, latency or token figure. I ran nothing for this article. The test above is how you get them.</li>
<li>Which OS version added Apple's <code>toolCallingMode</code> and the dynamic profile API. The pages I read do not say.</li>
<li>A token limit for Gemini Nano. Google's pages mention limits and give no number.</li>
<li>Which Android phones support image input. The device list covers the Prompt API as a whole, and I did not find an image-specific list.</li>
<li>Whether the ADK for Android <code>GenaiPrompt</code> path can execute tools. A third-party post says it cannot. Google's <code>AgentRunner</code> page says tool calling works through ML Kit.</li>
<li>Google's thinking mode. A Google page mentions it, and I did not fetch it.</li>
<li>Whether Apple's model refuses on a typed request in every case. Apple's page says it throws a <code>refusal</code> error. I did not test it.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Same Task, Three Models: Your First Call to Apple's, Google's and an External AI]]></title><description><![CDATA[One task written three ways: Apple Foundation Models, Gemini Nano Prompt API and a cloud API, with the availability check each needs.]]></description><link>https://sairamkotha.hashnode.dev/same-task-three-models-your-first-call-to-apple-s-google-s-and-an-external-ai</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/same-task-three-models-your-first-call-to-apple-s-google-s-and-an-external-ai</guid><category><![CDATA[on-device ai]]></category><category><![CDATA[Apple Foundation Models]]></category><category><![CDATA[gemini-nano]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Thu, 29 Oct 2026 15:05:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/2453b31e-93b2-4230-b8b7-dde9ff831aaf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Part 2 of 10 in the series on using on-device AI in your app. <a href="https://www.linkedin.com/pulse/your-apps-ai-can-run-three-places-each-one-sends-data-sai-ram-kotha-a1gbc/">Part 1</a> covered where the model runs. This part writes one small task three ways, so you can see what each API asks of your code, and where each one will surprise you.</p>
<p>The task: take a customer review and return a sentiment, one word, <code>positive</code>, <code>neutral</code> or <code>negative</code>. It is small on purpose. The point is the plumbing around the call, not the answer.</p>
<h2>Apple: check first, then ask</h2>
<p>Apple's on-device model is <code>SystemLanguageModel</code>. Apple's documentation says to check availability before you create a session, and to "plan for a fallback experience in case the model is unavailable." It can also take time: Apple says the model may still be downloading after someone turns on Apple Intelligence.</p>
<pre><code class="language-swift">import FoundationModels

func sentiment(for review: String) async throws -&gt; String? {
    let model = SystemLanguageModel.default
    switch model.availability {
    case .available:
        break
    case .unavailable(.deviceNotEligible):
        return nil          // Permanent. Show the non-AI version.
    case .unavailable(.appleIntelligenceNotEnabled):
        return nil          // The user can turn it on in Settings.
    case .unavailable(.modelNotReady):
        return nil          // Downloading, or another system reason. Retry later.
    case .unavailable(let other):
        return nil          // A reason Apple has not listed yet.
    }

    // Single-turn task: a new session each time.
    let session = LanguageModelSession(
        instructions: "Classify the review as positive, neutral or negative. Reply with one word."
    )
    let response = try await session.respond(to: review)
    return response.content
}
</code></pre>
<p>Four details that matter more than the code:</p>
<ul>
<li><strong>One session per review.</strong> Apple says to "create a new session each time you call the model" for a single-turn task, and to reuse a session only for multi-turn chat. A session keeps a transcript, and the transcript counts toward the context window. Reusing one session across a hundred reviews fills it.</li>
<li><strong>Instructions are yours, the review is the prompt.</strong> Apple trains the model to obey instructions over prompts, so keep the fixed text in the instructions and the user's text in the prompt.</li>
<li><strong>Check Apple's own list of what the model is for.</strong> Apple lists classify and judge text, summarize, extract entities and tag as good fits. It lists basic math, code generation and logical reasoning as things to avoid. A sentiment label is on the good list. A feature that adds up an order total is not.</li>
<li><strong>The content tagging model is not a classifier.</strong> Apple ships a second use case, <code>SystemLanguageModel(useCase: .contentTagging)</code>, which "always responds with tags": short lowercase topic, action, object and emotion words. It will give you an emotion tag like "delight," not one of three fixed labels. For a fixed label set, use the general model and a typed result, which Part 3 covers.</li>
</ul>
<p>Reviews also carry a trap. Apple's guardrails check both the prompt and the output. A review with profanity or a sensitive topic can throw a guardrail error instead of returning a label. Apple's <code>permissiveContentTransformations</code> setting lets a <code>String</code> response go through without throwing, though the model can still refuse. When you ask for a typed value instead of a string, the permissive mode behaves like the default and still throws. Plan for both: catch the error and show "could not classify," not a crash.</p>
<p>If you know the user is about to ask, Apple's <code>prewarm(promptPrefix:)</code> loads the model early. Apple says to use it only when you have at least one second before the request, and that the system may not honour it if the app is in the background or the device is under load.</p>
<h2>Google: a status check with four answers</h2>
<p>On Android, Gemini Nano runs through ML Kit's direct model access. Google's integration guide gives the status check four values, not two: <code>UNAVAILABLE</code>, <code>DOWNLOADABLE</code>, <code>DOWNLOADING</code> and <code>AVAILABLE</code>. The model is only usable when it is <code>AVAILABLE</code>. When it is <code>DOWNLOADABLE</code>, your app is the one that starts the download.</p>
<pre><code class="language-kotlin">import com.google.mlkit.genai.prompt.Generation

class SentimentModel {
    private val model = Generation.getClient()

    suspend fun classify(review: String): String? {
        when (model.checkStatus()) {
            FeatureStatus.UNAVAILABLE -&gt; return null      // Unsupported, or config not fetched yet.
            FeatureStatus.DOWNLOADING -&gt; return null      // Try again later.
            FeatureStatus.DOWNLOADABLE -&gt; {
                model.download().collect { status -&gt;
                    when (status) {
                        is DownloadStatus.DownloadStarted -&gt; { /* show progress */ }
                        is DownloadStatus.DownloadProgress -&gt; { /* status.totalBytesDownloaded */ }
                        DownloadStatus.DownloadCompleted -&gt; { /* ready */ }
                        is DownloadStatus.DownloadFailed -&gt; { /* status.e.message */ }
                    }
                }
                return null                                // Answer on the next call.
            }
            FeatureStatus.AVAILABLE -&gt; Unit
        }

        val request = generateContentRequest(
            SystemInstruction("Classify the review as positive, neutral or negative. Reply with one word."),
            TextPart(review)
        )
        return try {
            model.generateContent(request).candidates.firstOrNull()?.text
        } catch (e: GenAiException) {
            null
        }
    }

    fun close() = model.close()   // Call from onCleared() or onDestroy().
}
</code></pre>
<p>The structure comes from Google's sample code. The names <code>Generation.getClient()</code>, <code>checkStatus()</code>, <code>download()</code>, <code>FeatureStatus</code>, <code>DownloadStatus</code>, <code>generateContentRequest</code>, <code>SystemInstruction</code>, <code>TextPart</code>, <code>candidates</code> and <code>GenAiException</code> all appear on Google's Prompt API pages. I have not compiled this exact class.</p>
<p>Six details that matter:</p>
<ul>
<li><strong>System instructions need a newer model.</strong> Google's page says they "work on devices running Gemini Nano V3 and higher." On an older device, put the instruction text in the prompt instead. Google also recommends keeping system instructions under 150 words, and counting them in your token estimate.</li>
<li><strong>Close the client.</strong> Google says to call <code>close()</code> when the Activity, Fragment or ViewModel is destroyed. That is why the model lives in a class with a <code>close()</code>, not in a one-off function.</li>
<li><strong>Warm up for the first call.</strong> Google offers an optional <code>warmup()</code> that loads Gemini Nano into memory before the first inference. Apple's equivalent is <code>prewarm</code>.</li>
<li><strong>Foreground only.</strong> Google's overview says inference is allowed only when your app is the top foreground application. A background job gets <code>BACKGROUND_USE_BLOCKED</code>.</li>
<li><strong>Streaming is a separate call.</strong> <code>generateContentStream</code> returns chunks. Google says to use streaming for long replies and the non-streaming call for short ones. A one-word label is short.</li>
<li><strong>The version numbers disagree.</strong> Google's get-started page shows <code>com.google.mlkit:genai-prompt:1.0.0-beta2</code>. Google's integration skill says to use "at least <code>1.0.0-beta4</code>." The API is in beta and Google says it can "break backward compatibility." Pin the version and read its release notes before you update.</li>
</ul>
<p>Typed output exists here too, with the same names Apple uses. Google's structured output page uses <code>@Generable</code> and <code>@Guide</code> annotations, but it is Kotlin only, needs the KSP plugin at version 2.3.6 or higher and a separate schema-compiler dependency that Google lists as <code>1.0.0-alpha1</code>, and can break under ProGuard unless you add keep rules. Part 3 compares the two.</p>
<h2>External model: never from the phone</h2>
<p>The third call goes to an external model, such as Claude. The rule from Part 1 applies: the API key does not go in the app. Your app calls your own backend, and the backend calls the provider.</p>
<p>On the phone, this is an ordinary network call:</p>
<pre><code class="language-swift">struct SentimentRequest: Encodable { let review: String }
struct SentimentResponse: Decodable { let sentiment: String }

func sentimentFromServer(review: String) async throws -&gt; String {
    var request = URLRequest(url: URL(string: "https://api.example.com/sentiment")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(SentimentRequest(review: review))

    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(SentimentResponse.self, from: data).sentiment
}
</code></pre>
<p>On your backend, the key stays in an environment variable. This is the request shape from Anthropic's quickstart:</p>
<pre><code class="language-bash">curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -d '{
    "model": "claude-haiku-5-5",
    "max_tokens": 5,
    "system": "Classify the review as positive, neutral or negative. Reply with one word.",
    "messages": [{"role": "user", "content": "The battery lasts all day. Love it."}]
  }'
</code></pre>
<p>Four details:</p>
<ul>
<li><strong>The reply is a list of blocks.</strong> The response has a <code>content</code> array, and the quickstart's own code loops over it and keeps the blocks whose type is <code>text</code>. Do not read <code>content[0]</code> blindly.</li>
<li><strong>The usage comes with the reply.</strong> The response includes <code>usage</code> with <code>input_tokens</code> and <code>output_tokens</code>. That is the external side of your receipt.</li>
<li><strong>The fee is per request.</strong> The backend is where you put the spend limit and the rate limit, so a bug in one app version cannot run up the bill.</li>
<li><strong>The model name is a setting.</strong> Keep it in configuration, so you can move to a smaller model without an app release. The quickstart I fetched shows <code>claude-opus-5-5</code> as its example, and <code>claude-haiku-5-5</code> is the smaller model I used here. Check the current model list when you build.</li>
</ul>
<h2>The same receipt from all three</h2>
<p>Whichever path answers, log one record: which backend answered, how long it took, and the token counts. Apple reports tokens on each response's <code>usage</code>, with input and output counts and a total, and a running total on the session. Anthropic returns <code>usage</code> with <code>input_tokens</code> and <code>output_tokens</code>. I did not find a documented token count in Google's direct-access response, so for Gemini Nano log the time and your own estimate. Part 6 builds the comparison on this record.</p>
<h2>What this series does next</h2>
<p><a href="https://sairamkotha.hashnode.dev/what-the-phone-s-built-in-ai-can-and-cannot-do-limits-typed-output-tools-and-images">Part 3</a> covers what the default models can and cannot do: the context limits, typed output on each platform, and tool calling. You will run the same review set through each path and see where the answers differ.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<p><strong>Apple (fetched as Apple's documentation JSON)</strong></p>
<ul>
<li><a href="https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models">Generating content and performing tasks with Foundation Models</a>. Availability sample, "plan for a fallback," one session per single-turn call, the capability and "avoid" lists.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/categorizing-and-organizing-data-with-content-tags">Categorizing and organizing data with content tags</a> and <a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/usecase/contenttagging">SystemLanguageModel.UseCase.contentTagging</a>. "Always responds with tags."</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/guardrails/permissivecontenttransformations">SystemLanguageModel.Guardrails.permissiveContentTransformations</a>. String versus typed behaviour.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/prewarm(promptprefix:)">LanguageModelSession.prewarm(promptPrefix:)</a> and <a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/usage-swift.struct">LanguageModelSession.Usage</a>.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/availability-swift.enum/unavailablereason">SystemLanguageModel.Availability.UnavailableReason</a>.</li>
<li><a href="https://developer.apple.com/videos/play/wwdc2025/286/">WWDC25: Meet the Foundation Models framework</a>. Instructions versus prompts.</li>
</ul>
<p><strong>Google (fetched)</strong></p>
<ul>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/references/get-started.md">Prompt API get-started guide</a>. <code>Generation.getClient()</code>, <code>checkStatus()</code>, the four <code>FeatureStatus</code> values, <code>download()</code>, <code>generateContent</code>, <code>generateContentStream</code>, <code>warmup()</code>, API level 26, <code>1.0.0-beta2</code>.</li>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/references/system-instructions.md">System instructions guide</a>. Nano V3 and higher, the 150-word advice, <code>SystemInstruction</code>.</li>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/references/structured-output.md">Structured output guide</a>. <code>@Generable</code>, <code>@Guide</code>, KSP 2.3.6, schema compiler <code>1.0.0-alpha1</code>, Kotlin only, ProGuard.</li>
<li><a href="https://github.com/android/skills/blob/main/device-ai/ml-kit-genai-prompt-api/SKILL.md">Integration skill</a>. "At least <code>1.0.0-beta4</code>," <code>close()</code>.</li>
<li><a href="https://developers.google.com/ml-kit/genai/prompt/android">Send custom requests with Gemini Nano direct model access</a>. Beta notice.</li>
<li><a href="https://developers.google.com/ml-kit/genai">Overview of the ML Kit GenAI APIs</a>. Foreground-only rule, <code>BACKGROUND_USE_BLOCKED</code>.</li>
</ul>
<p><strong>Anthropic (fetched)</strong></p>
<ul>
<li><a href="https://platform.claude.com/docs/en/get-started">Get started with Claude</a>. The curl request, headers, response shape and <code>usage</code> fields.</li>
<li><a href="https://platform.claude.com/docs/en/api/messages">Messages API reference</a>. The page I fetched was truncated, so I used only the fields it showed.</li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>That the Kotlin class compiles. The API names are from Google's pages, and I did not build the code. The <code>generateContentRequest</code> import path and the exact <code>GenAiException</code> package are not shown on the pages I read.</li>
<li>Whether the foreground-only rule still applies on every Nano version. The overview page states it without a version.</li>
<li>A token count in Google's direct-access response. I found none documented.</li>
<li>That <code>claude-haiku-5-5</code> is current on the day you publish. The quickstart I fetched uses <code>claude-opus-5-5</code> as its example.</li>
<li>Whether Anthropic requires <code>max_tokens</code> on every request. The Messages reference I fetched lists it as a body parameter but did not state that it is required.</li>
<li>Latency, cost and accuracy for each path on a real review set. Parts 3 and 6 measure them.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Your App's AI Can Run in Three Places. Each One Sends Your Data Somewhere Else.]]></title><description><![CDATA[Three places an app's AI can run, what each sends off the device, and how to pick one for each feature.]]></description><link>https://sairamkotha.hashnode.dev/your-app-s-ai-can-run-in-three-places-each-one-sends-your-data-somewhere-else</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/your-app-s-ai-can-run-in-three-places-each-one-sends-your-data-somewhere-else</guid><category><![CDATA[on-device ai]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><category><![CDATA[AI Architecture]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Wed, 28 Oct 2026 18:37:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/ae09e0ea-6c97-48ac-9f0a-ec0671338946.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You want an AI feature in your app. The first decision is not which model to use. It is where the model runs, because that choice decides who sees the user's text, who pays for each reply, and whether the feature works on a plane.</p>
<p>This is Part 1 of 10 in a series on using the AI built into the phone, and comparing it with the models you can rent from outside. This part covers the three places, what each one costs, and the details in Apple's and Google's documentation that catch teams after launch.</p>
<h2>The three places</h2>
<p><strong>On the phone.</strong> The model runs on the user's device. On iPhone it is Apple's Foundation Models framework, which Apple calls the <code>SystemLanguageModel</code>. On Android it is Gemini Nano, which runs inside a system service called AICore, and your app reaches it through ML Kit.</p>
<p><strong>A private cloud run by the platform.</strong> Apple also offers a larger model on its own servers, the Private Cloud Compute model, or PCC. Your app calls it through the same framework. Apple's documentation says switching to it is a one-line change.</p>
<p><strong>An external cloud.</strong> Claude, the Gemini API, OpenAI and others. Your app, or your backend, sends the prompt to the provider's servers and gets the reply back.</p>
<h2>What each one means for you</h2>
<table>
<thead>
<tr>
<th></th>
<th>On the phone</th>
<th>Apple's private cloud</th>
<th>External cloud</th>
</tr>
</thead>
<tbody><tr>
<td>Where the text goes</td>
<td>Stays on the device</td>
<td>Apple's servers. Apple says no prompts are stored</td>
<td>The provider's servers, under its terms</td>
</tr>
<tr>
<td>Usage fee to you</td>
<td>None</td>
<td>None for apps under Apple's download threshold</td>
<td>Per token, billed to you</td>
</tr>
<tr>
<td>Usage limit on the user</td>
<td>Unlimited on iPhone. Per-app quotas on Android</td>
<td>A daily per-user limit</td>
<td>Whatever you set</td>
</tr>
<tr>
<td>Cost to the user</td>
<td>Battery and heat</td>
<td>Network and battery</td>
<td>Network and battery</td>
</tr>
<tr>
<td>Works offline</td>
<td>Yes, if the model is on the device</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td>Context size</td>
<td>4,096 tokens on iPhone, per Apple's error docs</td>
<td>32,000 tokens</td>
<td>Model dependent, usually far larger</td>
</tr>
<tr>
<td>What you must set up</td>
<td>Availability checks, fallback UI</td>
<td>An entitlement from Apple</td>
<td>API keys, billing, a backend</td>
</tr>
</tbody></table>
<p>Apple's own comparison table for its two models says the same thing in five rows: both preserve privacy, only the on-device model works offline, the on-device model has unlimited usage while PCC has a limit per day, only PCC supports reasoning, and the context sizes are 4K and 32K.</p>
<p>Read the whole table as a trade. The on-device path keeps data local and works offline, but it is the smallest model. The external path has the strongest model, but each reply carries a fee and a data-handling agreement.</p>
<h2>Hidden detail 1: "available" is not a yes or no</h2>
<p>Apple's framework does not have an on/off switch. The availability value is an enum with two cases. When it is unavailable, it carries a reason. Apple's documentation lists three:</p>
<ul>
<li><code>deviceNotEligible</code>: "The device does not support Apple Intelligence." Permanent. Hide the feature or show the non-AI version.</li>
<li><code>appleIntelligenceNotEnabled</code>: "Apple Intelligence is not enabled on the system." The user can turn it on in Settings. Prompt once. Do not nag.</li>
<li><code>modelNotReady</code>: "The models aren't available on the user's device." Apple's sample comments that this covers downloading "or other system reasons." Temporary. Retry later and do not show an error.</li>
</ul>
<p>Apple's sample also keeps a catch-all branch for reasons that do not exist yet:</p>
<pre><code class="language-swift">switch model.availability {
case .available:
    // Show your intelligence UI.
case .unavailable(.deviceNotEligible):
    // Show an alternative UI.
case .unavailable(.modelNotReady):
    // The model isn't ready because it's downloading or because
    // of other system reasons.
case .unavailable(let other):
    // The model is unavailable for an unknown reason.
}
</code></pre>
<p>One more case hides behind the check. Apple's <code>assetsUnavailable</code> error says the model's files can disappear while your app is running, for example "if the user disables Apple Intelligence while your app is running," and that you "may be able to recover from this error by retrying later after the device has freed up enough space to redownload model assets." So the availability check is not a one-time gate. The model can leave mid-session.</p>
<p>Gemini Nano has the same shape. Google's own guidance says the model "must be fully downloaded and available before calling the first inference," and that you check its <code>FeatureStatus</code> is <code>AVAILABLE</code> first.</p>
<h2>Hidden detail 2: the on-device model has a context of 4,096 tokens, and it counts everything</h2>
<p>Apple's error documentation for <code>exceededContextWindowSize</code> is the clearest statement I found: the error "occurs when you use the available tokens for the context window of 4,096 tokens. The token count includes instructions, prompts, and outputs for a session instance."</p>
<p>It also gives a rough token rule: "approximately three to four characters in languages like English, Spanish, or German, and one token per character in languages like Japanese, Chinese, and Korean." A Japanese prompt spends the window about four times faster than an English one.</p>
<p>Do not hardcode 4,096. Apple's <code>contextSize</code> property returns "the total number of tokens that can be used in a single session, including both input prompts and generated responses," and Apple's WWDC26 session prints 8,192 from a newer model. Article 21 in this series covers why three numbers exist. Read the value from the device, and when the session overflows, Apple's advice is to "start a new session" and "try again using a shorter prompt or shorter output length."</p>
<p>Apple also gives you the receipt. Each response has a <code>Usage</code> value with input and output token counts, and the session keeps an accumulated <code>usage</code> total. That is the number to log.</p>
<h2>Hidden detail 3: the model changes under you</h2>
<p>Apple's documentation says it "periodically updates <code>SystemLanguageModel</code> in routine OS updates," and that there are currently three model versions, aligned to OS 26.0 to 26.3, to 26.4, and to 27.0. Apple's guide on updating prompts says to test your prompts against the old and new model, record what the old model produced, and version your prompts, for example by OS availability checks or by fetching them from a server.</p>
<p>Google says the same about Gemini Nano. Its overview page lists devices by model version, nano-v2, nano-v3 and nano-v4, and warns that "different versions of Gemini Nano may return different output from the same prompt." There is a <code>getBaseModelName()</code> call to find out which version a device runs.</p>
<p>For a product team, this means an on-device prompt is not written once. It is retested every OS release, on every model version your users have.</p>
<h2>Hidden detail 4: Android has quotas and a foreground rule</h2>
<p>Apple says on-device usage is unlimited, with one exception: the <code>rateLimited</code> error "will only happen if your app is running in the background and exceeds the system defined rate limit."</p>
<p>Google is stricter. Its ML Kit GenAI overview says "AICore enforces an inference quota per app," that too many requests in a short period return <code>ErrorCode.BUSY</code>, and that a long-duration quota such as a daily quota can return <code>ErrorCode.PER_APP_BATTERY_USE_QUOTA_EXCEEDED</code>. Google suggests exponential backoff for <code>BUSY</code>.</p>
<p>The foreground rule is the one that breaks designs. Google's page says inference "is permitted only when the app is the top foreground application," and that running it from the background, "including using a foreground service," returns <code>ErrorCode.BACKGROUND_USE_BLOCKED</code>. A background summarizer, an indexing job, or a notification pipeline cannot use Gemini Nano through this API. Plan those features for the foreground, or for the cloud.</p>
<h2>Hidden detail 5: the Android device list is long, specific and versioned</h2>
<p>Google's overview page now lists the devices that support direct model access, grouped by Nano version. The nano-v3 group includes the Pixel 9 and Pixel 10 families, Samsung's Galaxy S26 family, OnePlus 15, OPPO Find X8 and X9, Sony Xperia 1 VIII, and several vivo, Honor and Lenovo devices. The nano-v2 group covers older flagships from Xiaomi, OnePlus, Motorola, Samsung foldables and others. A nano-v4 group lists the Pixel 11 family and Samsung's Galaxy Z Flip8 and Fold8.</p>
<p>Two things to notice. The Pixel 8 family is not in the direct-access list. And Samsung's Galaxy S25 appears only in the list for the task-specific APIs (summarization, proofreading, rewriting, image description), not in the list for custom prompts. Which API you use decides which phones you reach.</p>
<p>Google also notes that language support "may vary depending on the particular device's configuration and the models that have been downloaded to the device." Check the device list and the language before you promise the feature in a market.</p>
<h2>Hidden detail 6: Apple's cloud has a daily budget, and a button to buy more</h2>
<p>Apple's PCC model is for when the on-device model is not enough. Apple's guidance is to "start with the on-device model and evaluate it," then move to PCC if your feature needs more reasoning or context. PCC needs iOS 27 or later, a network connection, a device that supports Apple Intelligence, and an entitlement that Apple grants after an eligibility review.</p>
<p>The hidden part is the quota. Apple's documentation says people get "a daily request limit" and "can upgrade their iCloud+ subscription to get more access." The framework exposes this as <code>quotaUsage</code>, with <code>isLimitReached</code>, a status that reports when a person is approaching the limit, a <code>resetDate</code>, and a <code>limitIncreaseSuggestion</code> that shows Apple's upgrade UI. Apple asks you to show the person their usage status in your UI, not just an alert, and to treat <code>quotaLimitReached</code> differently from a rate limit, because the person has to wait for the reset or upgrade.</p>
<p>So the "free" tier has a meter on it, and the meter belongs to the user, not to you. The WWDC26 cost note still holds: Apple says there are no cloud API costs for developers with fewer than 2 million first-time downloads. Both statements are true at once. You do not pay, and the user has a daily cap.</p>
<p>PCC has its own unavailable reasons, <code>deviceNotEligible</code> and <code>systemNotReady</code>. Apple's advice when the network fails is to "retry the request using the on-device model."</p>
<h2>Hidden detail 7: safety is a layer you partly control on iPhone and not at all on PCC</h2>
<p>Apple's safety guide says the framework has two base layers: models trained to handle sensitive topics, and guardrails that check both the input prompt and the output. On the on-device model you can choose a guardrail level through <code>SystemLanguageModel.Guardrails</code>, which has a default and a <code>permissiveContentTransformations</code> option. On PCC the guardrails "have different policies that you can't directly configure."</p>
<p>Two behaviors matter for your UI. A guardrail violation throws an error. A refusal may not. Apple says that for a plain string response, a refusal "might begin with" text like "Sorry, I can't help with that," and that you "might not be able to programmatically determine whether a string response is a normal response or a refusal." If you use guided generation to get a typed Swift value instead, a refusal throws a <code>refusal</code> error you can catch and explain.</p>
<p>External models have their own policies and their own refusal styles. Whichever path you pick, design the screen for the refusal, not only the answer.</p>
<h2>Hidden detail 8: the fallback is the product, and Google's SDK will tell you which model answered</h2>
<p>Google's hybrid inference page says cloud models are the fallback when on-device models "are unavailable due to device hardware or OS constraints," and that routine tasks on the device "reduce cloud inference costs." The Firebase AI Logic Hybrid API has four modes:</p>
<ul>
<li><code>PREFER_ON_DEVICE</code>: try the device, fall back to cloud if unavailable or unsupported.</li>
<li><code>PREFER_IN_CLOUD</code>: try the cloud when online, fall back to the device only when offline.</li>
<li><code>ONLY_ON_DEVICE</code> and <code>ONLY_IN_CLOUD</code>: no fallback, throw instead.</li>
</ul>
<pre><code class="language-kotlin">val model = Firebase.ai(backend = GenerativeBackend.googleAI())
    .generativeModel(
        modelName = "gemini-3.5-flash",
        onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE)
    )
</code></pre>
<p>Three details hide in the configuration page. The SDK "does not have a default cloud-hosted model," so you must name one for every mode that can reach the cloud. The response carries an <code>inferenceSource</code> property that says whether the device or the cloud answered, which is the field to log. And the feature is Experimental, "not subject to any SLA or deprecation policy."</p>
<p>Google's page also lists the inputs that apps like Gboard use for custom routing: network latency, battery level, processor load and query complexity. Articles 31 and 32 cover how to read the battery and heat signals.</p>
<p>Apple's version of the fallback is the <code>LanguageModel</code> protocol. WWDC26 says <code>SystemLanguageModel</code> and <code>PrivateCloudComputeLanguageModel</code> both conform to it, that Apple is open-sourcing <code>CoreAILanguageModel</code> and <code>MLXLanguageModel</code> for other local models, and that Anthropic and Google publish Swift packages for their server models. The session code stays the same; the backend changes. Apple's warning for third-party models: you handle authentication and per-token billing, and "never store private keys in your app binary."</p>
<h2>Hidden detail 9: it is built in, but it is still beta on Android</h2>
<p>Apple says the on-device model is "built into the operating system, so it won't increase your app size." Google says AICore manages the model download and updates, and that task APIs share the base model, which "conserves storage space." Neither adds weight to your binary.</p>
<p>The release risk is elsewhere. Google's Prompt API page says the API "is offered in beta, and is not subject to any SLA or deprecation policy. Changes may be made to this API that break backward compatibility." Google's own integration guide sets the minimum as Android API level 26 and version <code>1.0.0-beta4</code> of <code>com.google.mlkit:genai-prompt</code>. The ML Kit GenAI terms add that you must tell users about "Google's processing of metrics data." A feature built on this needs a fallback path and a plan for an API change.</p>
<h2>Where the cost actually lands</h2>
<p>The on-device call has no usage fee from Apple or Google. The phone still pays. Apple's <code>prewarm</code> method, which loads the model before the first prompt, says it should only be called with at least a one-second window before the request, and that the system may not load the assets "if your app is running in the background or the system is under load." That is a model competing for the same chip as the rest of the phone. A long session warms it, drains the battery and, as article 31 shows, can slow the reply.</p>
<p>The external call costs you on every reply, and grows with the conversation. Article 12 covers how that bill grows. The cost is visible, which makes it easier to plan.</p>
<h2>A quick way to choose</h2>
<p>Ask three questions about one feature:</p>
<ol>
<li><strong>Must the text stay on the device?</strong> If yes, start on-device, and stop there unless quality fails.</li>
<li><strong>Must it work with no network, or in the background?</strong> Offline means on-device. Background on Android means cloud, because Gemini Nano refuses background calls.</li>
<li><strong>Does it need the strongest model, or more than 4,096 tokens?</strong> Long reasoning, coding and long documents usually do. Summaries, rewrites and short suggestions usually do not.</li>
</ol>
<p>Most features answer these three questions differently. Part 4 covers that decision in detail.</p>
<h2>What this series will do next</h2>
<p><a href="https://sairamkotha.hashnode.dev/same-task-three-models-your-first-call-to-apple-s-google-s-and-an-external-ai">Part 2</a> writes the same small task three ways: Apple's <code>LanguageModelSession</code>, Gemini Nano through ML Kit's direct model access, and one external call. You will see the availability check, the token receipt, and the error cases above in working code.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<p><strong>Apple developer documentation (fetched as Apple's documentation JSON)</strong></p>
<ul>
<li><a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel">SystemLanguageModel</a>. Model versions by OS, availability sample.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/availability-swift.enum/unavailablereason">SystemLanguageModel.Availability.UnavailableReason</a>. The three reasons and their one-line descriptions.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/contextsize">contextSize</a>. What the number includes.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror/exceededcontextwindowsize(_:)">GenerationError.exceededContextWindowSize</a>. The 4,096 figure and the characters-per-token rule.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror/assetsunavailable(_:)">GenerationError.assetsUnavailable</a>, <a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror/ratelimited(_:)">rateLimited</a>, <a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror/concurrentrequests(_:)">concurrentRequests</a>.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/usage-swift.struct">LanguageModelSession.Usage</a>, <a href="https://developer.apple.com/documentation/foundationmodels/languagemodelsession/prewarm(promptprefix:)">prewarm(promptPrefix:)</a>.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/adding-server-side-intelligence-with-private-cloud-compute">Adding server-side intelligence with Private Cloud Compute</a>. The comparison table, daily limit, iCloud+ note, quota API, network fallback advice.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/privatecloudcomputelanguagemodel/quotausage-swift.struct">PrivateCloudComputeLanguageModel.QuotaUsage</a>.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/updating-prompts-for-new-model-versions">Updating prompts for new model versions</a>.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/improving-the-safety-of-generative-model-output">Improving the safety of generative model output</a>.</li>
<li><a href="https://developer.apple.com/documentation/foundationmodels/supporting-languages-and-locales-with-foundation-models">Supporting languages and locales with Foundation Models</a>.</li>
<li><a href="https://developer.apple.com/videos/play/wwdc2025/286/">WWDC25: Meet the Foundation Models framework</a>. Built-in, no app size increase, instructions versus prompts.</li>
<li><a href="https://developer.apple.com/videos/play/wwdc2026/241/">WWDC26: What's new in the Foundation Models framework</a>. The 8,192 <code>contextSize</code> output, the 2-million-download note, the <code>LanguageModel</code> protocol, third-party packages, billing and key advice.</li>
</ul>
<p><strong>Google (fetched)</strong></p>
<ul>
<li><a href="https://developers.google.com/ml-kit/genai">Overview of the ML Kit GenAI APIs</a>. Device lists by Nano version, <code>getBaseModelName()</code>, per-app quota, error codes, foreground rule, shared base model, language note.</li>
<li><a href="https://developers.google.com/ml-kit/genai/prompt/android">Send custom requests with Gemini Nano direct model access</a>. Beta status, inputs, multi-turn, structured output, task API trade-offs.</li>
<li><a href="https://developer.android.com/ai/gemini-nano">Gemini Nano on Android</a>. AICore, offline, privacy, memory budget statements.</li>
<li><a href="https://developer.android.com/ai/hybrid">Hybrid inference</a>. The four modes, cost and reach rationale, custom routing inputs, Gboard and Kakao Mobility examples.</li>
<li><a href="https://firebase.google.com/docs/ai-logic/hybrid/android/configuration-options">Configuration options for hybrid experiences in Android apps</a>. No default cloud model, <code>inferenceSource</code>, Experimental status, on-device model categories.</li>
<li><a href="https://developers.google.com/ml-kit/genai-terms">ML Kit GenAI API additional terms</a>. Metrics data disclosure, experimental technology.</li>
<li><a href="https://github.com/android/skills/tree/main/device-ai/ml-kit-genai-prompt-api">Google's ML Kit GenAI Prompt API integration skill</a>. API level 26, <code>1.0.0-beta4</code>, <code>FeatureStatus</code> check, <code>close()</code>, prefix caching above 200 words.</li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>The on-device context size on the device you ship to. Apple's error doc says 4,096, Apple's PCC table says 4K, and the WWDC26 sample prints 8,192. The number depends on the OS and model version, so read <code>contextSize</code> at runtime.</li>
<li>The 2-million-download threshold and what applies above it. The WWDC26 session states it briefly and points to a separate video on PCC.</li>
<li>The exact daily PCC request limit per user, and what iCloud+ tiers change. Apple's documentation describes the mechanism, not the numbers.</li>
<li>The Gemini Nano Prompt API token limits. Google's pages say "token limits" exist and do not give a number.</li>
<li>Whether the Android device list is complete on the day you publish. Google's page says support will expand, and it was last updated 2026-10-27.</li>
<li>Latency and cost figures for each path. This article makes no speed claims. Part 6 measures them.</li>
<li>Whether Apple charges anything for the on-device model. No Apple page I read states a price either way. The table's "None" rests on the absence of any fee in the documentation.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Your Phone Is Telling Your AI App It's in Trouble. Here's How to Listen.]]></title><description><![CDATA[A smoke detector only helps if it is plugged in, and if someone hears it. Phones work the same way. When an AI model runs on a phone, the operating system…]]></description><link>https://sairamkotha.hashnode.dev/your-phone-is-telling-your-ai-app-it-s-in-trouble-here-s-how-to-listen</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/your-phone-is-telling-your-ai-app-it-s-in-trouble-here-s-how-to-listen</guid><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><category><![CDATA[on-device ai]]></category><category><![CDATA[monitoring]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Tue, 27 Oct 2026 08:15:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/337637e0-f421-47eb-a8b8-c728aff50355.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A smoke detector only helps if it is plugged in, and if someone hears it.</p>
<p>Phones work the same way. When an AI model runs on a phone, the operating system knows when the phone is getting hot, when memory is running low, and why an app was shut down. It will tell your app, but only if the app signs up to listen. Most apps never do. They find out about the problem from a one-star review.</p>
<p>This article shows what iOS and Android can tell an app, how an app signs up, and what to do with the answers. Each section starts in plain words. Where engineers need more, a "For engineers" part follows with the exact calls and code.</p>
<p>It is the follow-up to three earlier articles: why on-device AI crashes after an hour, why it slows down when the phone heats up, and how to cool it down without losing the conversation. Those explained what goes wrong. This one explains how to see it happening.</p>
<h2>The big idea: two kinds of messages</h2>
<p>The phone sends two kinds of messages, and they need different handling.</p>
<ul>
<li><strong>Alarms, right now.</strong> "The phone is getting hot." "Memory is running low." These arrive while the app is running, so the app can react: shorten an answer, free some memory, pause a background job.</li>
<li><strong>Reports, later.</strong> "Yesterday, your app was shut down twice for using too much memory." These arrive after the fact. The app can't change what happened, but it learns why.</li>
</ul>
<p>You need both. Alarms protect the session in front of you. Reports tell you what keeps going wrong across all your users.</p>
<h2>What to watch</h2>
<table>
<thead>
<tr>
<th>What</th>
<th>In plain words</th>
<th>Why it matters for AI</th>
</tr>
</thead>
<tbody><tr>
<td>Heat</td>
<td>Is the phone getting too hot?</td>
<td>A hot phone slows itself down, so answers take longer</td>
</tr>
<tr>
<td>Memory</td>
<td>Is there room left?</td>
<td>A long chat keeps growing the model's working memory, until the system steps in</td>
</tr>
<tr>
<td>Shutdowns</td>
<td>Why did the app end?</td>
<td>"It crashed" tickets usually have a specific cause</td>
</tr>
<tr>
<td>Energy</td>
<td>How much battery did this cost?</td>
<td>Heavy AI use drains the battery and adds heat</td>
</tr>
<tr>
<td>Speed</td>
<td>Where did the time go?</td>
<td>"It feels slow" needs a number to fix</td>
</tr>
<tr>
<td>Memory reuse</td>
<td>Is the model reusing what it already read?</td>
<td>If not, every reply redoes old work</td>
</tr>
</tbody></table>
<p>The first three come from the phone's own signals, which this article covers. The last three you measure yourself. No phone reports them for you.</p>
<hr />
<h2>iPhone: five ways to listen</h2>
<h3>1. Heat</h3>
<p><strong>In plain words:</strong> iOS does not tell an app the temperature. It gives four labels instead: normal, a bit warm, hot, and too hot. Apple's own advice is to cut back as the labels climb. At "a bit warm", put off work that can wait. At "hot", do less. At "too hot", stop what you can.</p>
<p><strong>For engineers:</strong> <code>ProcessInfo.thermalState</code> has four values: <code>nominal</code>, <code>fair</code>, <code>serious</code> and <code>critical</code>. Apple's power guide says to register for the change notification instead of polling. Read the current value once at start, because the notification only fires on a change.</p>
<pre><code class="language-swift">final class ThermalWatcher {
    private var token: NSObjectProtocol?

    func start(onChange: @escaping (ProcessInfo.ThermalState) -&gt; Void) {
        onChange(ProcessInfo.processInfo.thermalState) // initial value
        token = NotificationCenter.default.addObserver(
            forName: ProcessInfo.thermalStateDidChangeNotification,
            object: nil, queue: .main
        ) { _ in onChange(ProcessInfo.processInfo.thermalState) }
    }

    deinit { token.map(NotificationCenter.default.removeObserver) }
}
</code></pre>
<p>There is no temperature API on iOS. Log the label, not a number you can't read.</p>
<h3>2. Running low on memory</h3>
<p><strong>In plain words:</strong> When the phone is short on memory, iOS first asks apps to give some back. If that doesn't work, it shuts an app down. Apple says these warnings are best-effort, so a sudden big demand can skip straight to the shutdown. A quiet log does not prove a safe session.</p>
<p><strong>For engineers:</strong> Apple lists four routes: the app delegate callback, the view controller callback, <code>didReceiveMemoryWarningNotification</code>, and a dispatch source of type <code>DISPATCH_SOURCE_TYPE_MEMORYPRESSURE</code>. For a model, the notification and the dispatch source are handy because you can register them from the inference layer, without a view controller.</p>
<pre><code class="language-swift">NotificationCenter.default.addObserver(
    forName: UIApplication.didReceiveMemoryWarningNotification,
    object: nil, queue: .main
) { _ in
    metrics.memoryWarnings += 1
    cache.dropColdEntries()
}
</code></pre>
<p>Pair the warning counter with a footprint reading on each reply. <code>os_proc_available_memory()</code> returns the bytes the process can still allocate. I could confirm it only through search results and a reference on Apple's entitlement page, so check the wording in your SDK. Call it once per reply, never per token.</p>
<h3>3. Daily reports from Apple (MetricKit)</h3>
<p><strong>In plain words:</strong> Apple will mail your app a daily summary from real users' phones: how much memory it used at its peak, how it was shut down, and more. If something serious happens, a separate report arrives right away. The app has to sign up first, and the earlier the better. The reports come once a day, so this is a history book, not a live feed.</p>
<p><strong>For engineers:</strong> Adopt <code>MXMetricManagerSubscriber</code>, then add the object to <code>MXMetricManager.shared</code>. This follows Apple's own example.</p>
<pre><code class="language-swift">final class AppMetrics: NSObject, MXMetricManagerSubscriber {
    func start() { MXMetricManager.shared.add(self) }
    func pause() { MXMetricManager.shared.remove(self) }

    // Daily metrics.
    func didReceive(_ payloads: [MXMetricPayload]) {
        payloads.forEach { upload($0.jsonRepresentation()) }
    }

    // Diagnostics, delivered as soon as they are available.
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        payloads.forEach { upload($0.jsonRepresentation()) }
    }
}
</code></pre>
<p>What Apple's documentation says:</p>
<ul>
<li>Metric reports arrive at most once per day per metric source, and may come as more than one payload a day.</li>
<li>A report covers the past 24 hours plus any earlier undelivered ones.</li>
<li>Diagnostic reports are delivered immediately on iOS 15 and later.</li>
<li>Adding a subscriber and receiving reports are safe in performance-sensitive code, such as app launch.</li>
<li>To test, use a physical device.</li>
<li><code>pastPayloads</code> and <code>pastDiagnosticPayloads</code> give access to reports you haven't processed.</li>
</ul>
<p>Register at launch, before the model loads. The useful parts for on-device AI are peak memory, termination counts by category, and the memory-exception diagnostic, which says the system ended the app for exceeding its limit.</p>
<h3>4. Your own stopwatch inside Apple's reports</h3>
<p><strong>In plain words:</strong> You can mark the stages of an AI reply, such as "reading the question" and "writing the answer", and Apple's daily report will include how often each ran and how long it took.</p>
<p><strong>For engineers:</strong> Create a log handle through the manager, then wrap each stage in a signpost.</p>
<pre><code class="language-swift">let log = MXMetricManager.makeLogHandle(category: "llm")

mxSignpost(.begin, log: log, name: "prefill")
// ... run prefill ...
mxSignpost(.end, log: log, name: "prefill")
</code></pre>
<p><code>makeLogHandle(category:)</code> is on Apple's <code>MXMetricManager</code> page. I did not confirm the exact <code>mxSignpost</code> signature, so check it in your SDK. The system limits how many custom metrics it keeps, so name only the main stages: model load, prefill, first token, decode. Never put a prompt or a user ID in a name.</p>
<hr />
<h2>Android: five ways to listen</h2>
<h3>1. Heat, as a label</h3>
<p><strong>In plain words:</strong> Like iOS, Android reports a heat level. It runs from "not throttled" up to "shutdown". The levels in the middle mean the phone is slowing itself down to cool off.</p>
<p><strong>For engineers:</strong> <code>PowerManager.addThermalStatusListener(executor, listener)</code> adds a listener for changes. Callbacks run on the executor you pass, so the main executor puts them on the main thread. Read <code>getCurrentThermalStatus()</code> once at start.</p>
<pre><code class="language-kotlin">val pm = getSystemService(PowerManager::class.java)
val listener = PowerManager.OnThermalStatusChangedListener { status -&gt;
    state.thermalStatus = status
    log("thermal_change", mapOf("status" to status))
}
pm.addThermalStatusListener(mainExecutor, listener)
// on teardown:
pm.removeThermalStatusListener(listener)
</code></pre>
<h3>2. Heat, as a number</h3>
<p><strong>In plain words:</strong> Android also offers a score for how close the phone is to being slowed down. Zero is cool. One means the phone is at the point where it starts throttling hard. You can even ask for a forecast a few seconds ahead. The catch: if you ask too often, it answers "not a number".</p>
<p><strong>For engineers:</strong> <code>getThermalHeadroom(forecastSeconds)</code> returns a float where 1.0 means the device is, or will be, throttled at <code>THERMAL_STATUS_SEVERE</code>. Values above 1.0 have no specific mapping to thermal levels. It returns <code>NaN</code> if the device doesn't support it, and the forecast range is 0 to 60 seconds.</p>
<p>On call rate my two sources differ. The <code>PowerManager</code> reference says calling more than about once per second may return <code>NaN</code>. The Android games thermal guide says not to call more than every 10 seconds. I used the slower one, and poll only while the status is elevated.</p>
<pre><code class="language-kotlin">private var lastHeadroomAt = 0L

fun headroomOrNull(): Float? {
    val now = SystemClock.elapsedRealtime()
    if (now - lastHeadroomAt &lt; 10_000) return null
    lastHeadroomAt = now
    return pm.getThermalHeadroom(0).takeUnless { it.isNaN() }
}
</code></pre>
<h3>3. Running low on memory</h3>
<p><strong>In plain words:</strong> Android sends the app a "please trim" message when the phone is short on memory. Only two of those messages matter now: "your screen just went away" and "you're in the background". Older, finer-grained messages stopped being sent in Android 14.</p>
<p><strong>For engineers:</strong> The Android memory guide says to act on <code>onTrimMemory</code> and to focus on <code>TRIM_MEMORY_UI_HIDDEN</code> and <code>TRIM_MEMORY_BACKGROUND</code>. The other legacy levels are no longer sent since Android 14 and were deprecated in Android 15.</p>
<pre><code class="language-kotlin">class App : Application() {
    override fun onTrimMemory(level: Int) {
        super.onTrimMemory(level)
        metrics.trimLevels += level
        if (level &gt;= TRIM_MEMORY_BACKGROUND) model.releaseColdState()
    }
}
</code></pre>
<p>The <code>ActivityManager.getMemoryInfo</code> reference says polling is not recommended and <code>onTrimMemory</code> is the preferred route. Take one <code>getMemoryInfo</code> reading per reply for the record, never in a loop.</p>
<h3>4. Why did the app die? (read on the next launch)</h3>
<p><strong>In plain words:</strong> Android keeps a short log of why your app's recent processes ended. When the app starts again, it can read the log and learn: was it shut down for low memory, or something else? There is one trap. Some phones can't report "low memory" and use a generic label instead, so a plain count would undercount. The app should check whether the phone can report it and save that answer with each record.</p>
<p><strong>For engineers:</strong> <code>ActivityManager.getHistoricalProcessExitReasons(packageName, pid, maxNum)</code> returns recent deaths, newest first, from a ring buffer, so only recent records survive. <code>null</code> for the package matches your own; <code>0</code> for pid and <code>0</code> for <code>maxNum</code> means no filter.</p>
<pre><code class="language-kotlin">fun collectExits(am: ActivityManager, lastSeen: Long) {
    val supported = ActivityManager.isLowMemoryKillReportSupported()
    am.getHistoricalProcessExitReasons(null, 0, 10)
        .filter { it.timestamp &gt; lastSeen }
        .forEach {
            log("exit", mapOf(
                "reason" to it.reason,          // e.g. REASON_LOW_MEMORY
                "importance" to it.importance,
                "rss_kb" to it.rss,
                "lmk_reported" to supported,
                "state" to it.processStateSummary?.decodeToString()
            ))
        }
}
</code></pre>
<p>From the reference:</p>
<ul>
<li><code>REASON_LOW_MEMORY</code> means the system's low-memory killer ended the process (API 30 and later).</li>
<li>Where unsupported, such a kill shows as <code>REASON_SIGNALED</code>.</li>
<li><code>REASON_MEMORY_LIMITER</code> is separate: the process exceeded the system memory limits.</li>
<li><code>getRss()</code> and <code>getPss()</code> are the last sampled values, not the exact memory before death. <code>getRss()</code> can be zero if the process died before a sample was taken.</li>
</ul>
<p>Save the timestamp of the newest record you processed, so each launch handles only new ones.</p>
<h3>5. A note left for the next launch</h3>
<p><strong>In plain words:</strong> Before it dies, the app can leave a tiny note, for example "model B, long chat, phone was warm". After the death, the note comes back inside the exit record. It is small, it must contain nothing private, and the app shouldn't write it too often.</p>
<p><strong>For engineers:</strong> <code>ActivityManager.setProcessStateSummary(byte[])</code> stores custom data in the <code>ApplicationExitInfo</code> record. The reference says it is for analysing app health, not restoring UI state, with these limits: the maximum is 128 bytes, no PII or other sensitive data, and the system may throttle calls, so excessive calls can throw a <code>RuntimeException</code>. Write on a state change, not on each reply.</p>
<pre><code class="language-kotlin">fun note(modelId: Int, ctxBucket: Int, thermal: Int) {
    am.setProcessStateSummary(byteArrayOf(modelId.toByte(), ctxBucket.toByte(), thermal.toByte()))
}
</code></pre>
<hr />
<h2>Android has no daily report. Here is what fills the gap.</h2>
<p>Apple sends a daily report into the app. Android doesn't have one single equivalent, so you combine four tools.</p>
<h3>Android vitals, in the Play Console</h3>
<p><strong>In plain words:</strong> Google collects quality numbers from users who allow it, and shows them in the Play Console: crash rate, "app not responding" rate, battery wake-ups and memory use. It's the nearest thing to a free report card. It is aggregated across users, not per chat.</p>
<p><strong>For engineers:</strong> The core vitals are user-perceived crash rate, user-perceived ANR rate, excessive partial wake locks, memory usage and bitmap memory usage. You can read them in the dashboard or through the Google Play Developer Reporting API. Memory is measured as anonymous RSS plus swap, with limits per RAM tier and app state. For apps on an 8 GB tier (usable memory 6,800 to 9,216 MB) the page lists 2.25 GB foreground and 1.5 GB for background services. On a 4 GB tier (3,200 to 4,800 MB) it lists 2.00 GB and 1.00 GB. The page says apps over the memory, bitmap or code-optimization thresholds may see store visibility affected from February 2027, and it asks you to log <code>ActivityManager.MemoryInfo.totalMem</code> against the device profile.</p>
<p>The page does not say whether memory-mapped model weights count as anonymous RSS. Measure your app's number on a real device and compare it with the tier limit before you assume you're under it.</p>
<h3>Perfetto battery counters (lab only)</h3>
<p><strong>In plain words:</strong> In the lab you can record how much battery the whole phone uses while the model runs. It measures the phone, not just your app, so you compare two runs: one with the model idle, one with it running.</p>
<p><strong>For engineers:</strong> Perfetto can read remaining charge (ÂµAh), capacity (%) and instantaneous current (ÂµA) from the battery. It needs power-management hardware, which Perfetto's page says most Pixel phones have, and resolution depends on the manufacturer. The counters cover the whole device. On USB the phone is charging and current reads positive, so use a USB hub that electrically disconnects the phone or run unplugged. The on-device power rails monitor, which measures per hardware block, needs dedicated hardware the page says most production phones lack. The sample config polls the battery every 250 ms.</p>
<h3>Trace sections (lab only)</h3>
<p><strong>In plain words:</strong> The app can drop labels into a recording, like chapter markers: "reading started", "reading finished". Open the recording beside the heat and battery tracks and you can see which chapter got slower when the phone warmed up.</p>
<p><strong>For engineers:</strong> <code>androidx.tracing</code>'s <code>Trace</code> writes async sections and counters into the system trace.</p>
<pre><code class="language-kotlin">Trace.beginAsyncSection("llm.prefill", turnIndex)
// ... prefill ...
Trace.endAsyncSection("llm.prefill", turnIndex)
Trace.setCounter("llm.cache_hit_tokens", cacheHit)
</code></pre>
<p>The name and cookie must match on begin and end. Overlapping sections need unique cookies. The trace stays on the device, so it doesn't feed a field dashboard.</p>
<h3>ProfilingManager, a recording from a real user</h3>
<p><strong>In plain words:</strong> On newer Android versions, the app can ask the system to record what was happening, and the file is saved to the app's own storage. It's rate limited and not guaranteed.</p>
<p><strong>For engineers:</strong> <code>ProfilingManager</code> (Android 15, API 35 and later) supports system traces, Java heap dumps, heap profiles and stack samples. Apps can register triggers so the system captures profiling on their behalf, and results arrive through a listener. Results are redacted and contain only information about the requesting process. I did not check which trigger types fit a "reply was slow" case, so read the trigger list before you design around it.</p>
<h3>Gemini Nano is a black box</h3>
<p><strong>In plain words:</strong> If your app uses Google's built-in Gemini Nano model, the model runs inside a protected system service. That is good for privacy, and it means the app can't see inside. All it can do is time the calls.</p>
<p><strong>For engineers:</strong> AICore is isolated, has no direct internet access, and per Google's page "doesn't store any record of the input data or the resulting outputs" after processing. I found no documented way to read its memory or cache use from the app. Log what your code sees: time to first output, time to the last, output length, and any error or unavailable state.</p>
<hr />
<h2>Put it together: one receipt per reply</h2>
<p><strong>In plain words:</strong> After each AI reply, the app writes a one-line receipt: how long it took, how warm the phone was, how much memory was left. No conversation text, only numbers and labels. Put the receipts from all users in one place and the patterns show up.</p>
<p><strong>For engineers:</strong> One record per reply, counts, durations and states only.</p>
<pre><code class="language-json">{
  "platform": "android", "turn_index": 27,
  "prompt_tokens": 1840, "cache_hit_tokens": 1790, "output_tokens": 212,
  "ttft_ms": 410, "decode_tok_s": 18.4,
  "thermal_state": 2, "thermal_headroom": 0.71,
  "memory_available_mb": 840, "trim_levels_in_turn": 0,
  "foreground": true
}
</code></pre>
<p>Keep it small, batch it, and send it on a schedule. The model fields (<code>prompt_tokens</code>, <code>cache_hit_tokens</code>, <code>ttft_ms</code>, <code>decode_tok_s</code>) come from your inference engine and timers. No platform API reports them. TTFT is time to first token: the wait between sending a question and seeing the first word of the answer.</p>
<h2>Which message is for what</h2>
<table>
<thead>
<tr>
<th>Message</th>
<th>Live or later</th>
<th>What to do with it</th>
</tr>
</thead>
<tbody><tr>
<td>Heat label changes</td>
<td>Live</td>
<td>Shorten replies, put off background jobs</td>
</tr>
<tr>
<td>Memory warning or trim</td>
<td>Live</td>
<td>Free cached data, release a backgrounded model</td>
</tr>
<tr>
<td>Apple daily report</td>
<td>Later</td>
<td>Track memory and shutdowns by app version</td>
</tr>
<tr>
<td>Apple diagnostic</td>
<td>Later</td>
<td>Find shutdowns for exceeding memory</td>
</tr>
<tr>
<td>Android exit record</td>
<td>Later</td>
<td>Measure low-memory kill rate per device</td>
</tr>
<tr>
<td>Android state note</td>
<td>Later</td>
<td>Know which model and chat length were live at the kill</td>
</tr>
<tr>
<td>Play Console vitals</td>
<td>Later</td>
<td>Compare memory and crashes with tier limits</td>
</tr>
<tr>
<td>Perfetto battery counters</td>
<td>Lab</td>
<td>Compare phone energy with model idle and running</td>
</tr>
<tr>
<td>Trace sections</td>
<td>Lab</td>
<td>See which stage slowed when the phone warmed up</td>
</tr>
<tr>
<td>ProfilingManager</td>
<td>Later</td>
<td>Get a recording from a real session</td>
</tr>
</tbody></table>
<h2>What to alert on</h2>
<p>Set the thresholds from your own lab data. These are the shapes that pay off:</p>
<ul>
<li><strong>Shutdowns per thousand sessions</strong>, by platform and app version. A jump after a release points at a memory change.</li>
<li><strong>Time to first token, split by heat label.</strong> If it rises only when the phone is warm, the OS is slowing the work. If it rises when cool, look at memory reuse.</li>
<li><strong>Memory reuse rate, by version.</strong> A drop usually means someone changed how the chat history is built.</li>
<li><strong>Sessions with a memory warning or trim</strong>, as a share of all sessions.</li>
</ul>
<h2>Mistakes to avoid</h2>
<ul>
<li><strong>Signing up late.</strong> Register at launch, before the model loads.</li>
<li><strong>Trusting silence.</strong> Memory warnings are best-effort. Add a memory reading to each receipt.</li>
<li><strong>Asking for the Android heat number in a loop.</strong> Use the listener for changes and a throttled read for the number.</li>
<li><strong>Logging what the user said.</strong> Prompts and replies are user data. Receipts hold states and counts.</li>
<li><strong>Trusting the Android low-memory count blindly.</strong> Some phones can't report it. Store the support flag.</li>
<li><strong>Treating Apple's report as live.</strong> It comes once a day. Use your own receipts for anything urgent.</li>
</ul>
<h2>What to do this week</h2>
<ol>
<li>Sign up for the heat and memory messages on both platforms, and write the latest values into each reply's receipt.</li>
<li>On iOS, subscribe to MetricKit at launch and upload both payload types.</li>
<li>On Android, read the exit records at each launch, and store the low-memory support flag.</li>
<li>Leave a small state note on Android when the heat level or model changes.</li>
<li>On Android, open Play Console vitals and compare your memory number with your RAM tier's limit. Add trace sections around prefill and decode, and record one Perfetto trace with the battery counters.</li>
<li>Chart time to first token by heat label. That one chart tells you whether you have a heat problem or a memory-reuse problem.</li>
</ol>
<p>The phone already knows when it is hot, short on memory, or about to end your app. It only tells the apps that are listening.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<p><strong>Apple (fetched)</strong></p>
<ul>
<li><a href="https://developer.apple.com/documentation/metrickit/mxmetricmanager">MXMetricManager</a>, through Apple's documentation JSON</li>
<li><a href="https://developer.apple.com/documentation/metrickit/mxmetricmanagersubscriber">MXMetricManagerSubscriber</a></li>
<li><a href="https://developer.apple.com/documentation/xcode/responding-to-power-notifications">Responding to power notifications</a></li>
<li><a href="https://developer.apple.com/documentation/xcode/responding-to-low-memory-warnings">Responding to low-memory warnings</a></li>
<li><a href="https://developer.apple.com/documentation/foundation/processinfo/thermalstate-swift.enum">ProcessInfo.ThermalState</a></li>
</ul>
<p><strong>Android (fetched)</strong></p>
<ul>
<li><a href="https://developer.android.com/reference/android/app/ApplicationExitInfo">ApplicationExitInfo</a></li>
<li><a href="https://developer.android.com/reference/android/app/ActivityManager">ActivityManager</a></li>
<li><a href="https://developer.android.com/reference/android/os/PowerManager">PowerManager</a></li>
<li><a href="https://developer.android.com/topic/performance/vitals">Android vitals</a></li>
<li><a href="https://developer.android.com/reference/android/os/ProfilingManager">ProfilingManager</a></li>
<li><a href="https://developer.android.com/reference/androidx/tracing/Trace">androidx.tracing Trace</a></li>
<li><a href="https://developer.android.com/ai/gemini-nano">Gemini Nano</a></li>
<li><a href="https://perfetto.dev/docs/data-sources/battery-counters">Perfetto: power data sources</a></li>
<li><a href="https://developer.android.com/games/optimize/adpf/thermal">Thermal API, Android Games ADPF</a></li>
<li><a href="https://developer.android.com/topic/performance/memory/manage-app-memory">Manage your app's memory</a></li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>The exact <code>mxSignpost</code> call signature. I confirmed <code>makeLogHandle(category:)</code> on Apple's page, not the signpost function. Check your SDK.</li>
<li><code>os_proc_available_memory()</code> wording. Search results and a reference only.</li>
<li>The Swift and Kotlin snippets are sketches built from the documented signatures. I did not compile or run them.</li>
<li>The <code>DISPATCH_SOURCE_TYPE_MEMORYPRESSURE</code> setup. Apple's page names it; I did not include code for it.</li>
<li>The headroom call rate. The PowerManager reference says about once per second; the games guide says 10 seconds. I used the slower one.</li>
<li>Whether <code>getRss()</code> and <code>getPss()</code> in exit records are enough to see a gradual memory climb. They are single last-sample values.</li>
<li>Whether memory-mapped model weights count toward Android vitals' anonymous RSS plus swap. The page does not say.</li>
<li>Which <code>ProfilingManager</code> trigger types fit a slow-reply case. I read the class summary, not the trigger list.</li>
<li>Any way to read AICore memory or cache use from an app. I found none documented.</li>
<li>Perfetto power rails on specific phones. The page says most production phones lack the hardware.</li>
<li>The Android vitals thresholds and the February 2027 date are as shown on Google's page today. Check the page before you quote them.</li>
<li>Any numbers on how many users hit each signal. None of this is measured on a live app.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Your On-Device AI Doesn't Crash in Minute One. It Crashes at Minute Sixty.]]></title><description><![CDATA[Why on-device AI apps get killed after an hour: KV cache growth, iOS jetsam reports, Android trim levels, and mitigations with sources.]]></description><link>https://sairamkotha.hashnode.dev/your-on-device-ai-doesn-t-crash-in-minute-one-it-crashes-at-minute-sixty</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/your-on-device-ai-doesn-t-crash-in-minute-one-it-crashes-at-minute-sixty</guid><category><![CDATA[AI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Mon, 26 Oct 2026 05:39:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/8993694b-d015-47bd-9628-d431698c8066.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The model loads, the first replies look fine, and the app feels great. An hour later the app is gone. No error on screen, no exception in your logs. The operating system stopped the process to protect the rest of the phone, and from the user's side it looks like a crash.</p>
<p>This article follows that failure from the mechanism to the fix. It uses Apple's and Google's own documentation where they publish it, marks where they don't, and gives you a way to read the report the system leaves behind.</p>
<h2>Why it takes an hour</h2>
<p>Model weights are a fixed cost. You pay them once, at load. What grows is the conversation.</p>
<p>Each token adds entries to the key-value (KV) cache, and the cache keeps growing for the life of the session. A KVSwap paper gives a concrete case: for Qwen3-4B at 16K context and batch size 4, the weights take 7.5 GiB and the KV cache reaches 9 GiB, already larger than the weights. That is a server-class setup, but the shape of the curve is the same on a phone.</p>
<p>A mobile study makes the same point. mzCache (arXiv 2609.01338) reports that a multitasking phone forces the OS to evict model weights and KV cache when the user switches apps, and that restoring them is slow. On a Galaxy S25+ and a OnePlus 12, storage reads account for 59 to 61% of time-to-first-token after eviction.</p>
<h2>How iOS decides to stop you</h2>
<p>The system sends warnings first. Apps free memory after a low-memory notification. If all apps together release enough memory, nothing happens. If pressure continues, the system terminates applications to reclaim memory. Apple calls that a jetsam event.</p>
<p>Apple says warnings reach your app four ways: applicationDidReceiveMemoryWarning on your app delegate, didReceiveMemoryWarning on active view controllers, the didReceiveMemoryWarningNotification, and a dispatch source of type DISPATCH_SOURCE_TYPE_MEMORYPRESSURE.</p>
<p>Warnings are best-effort. If memory demand rises faster than the warnings relieve pressure, the system may skip them and go straight to jetsam. Apple's rule: allocate large memory slowly, so the system has time to free memory.</p>
<h2>Read the report the system leaves behind</h2>
<p>When your app is jettisoned, iOS writes a jetsam event report. It contains the memory use of all apps and system processes, it is JSON, and it has no backtraces for your threads. If the system jettisons your app while it is visible, it looks like a crash. The report is the evidence that it wasn't one.</p>
<ul>
<li>pageSize in the header converts page counts to bytes. Apple's example uses 16,384 bytes.</li>
<li>largestProcess names the process using the most memory pages.</li>
<li>reason appears only on the killed process. per-process-limit means the process crossed the resident memory limit. vm-pageshortage means the system ran out of memory and needed background memory for the foreground app.</li>
</ul>
<p>To turn pages into bytes, multiply rpages by pageSize. Apple's example: 92,802 pages × 16,384 bytes = 1,520,467,968 bytes, about 1.52 GB.</p>
<p>If your 1-hour crash shows per-process-limit with a rising lifetimeMax, you have a growing allocation in your process. If it shows vm-pageshortage with your app in the background, the phone was short on memory and you were the process it chose.</p>
<h2>What you can do</h2>
<ul>
<li>Unbounded collections. A conversation history or an embedding cache that grows without a limit grows the same way Apple's scrolling message dictionary does. Discard old content, or write it to disk.</li>
<li>Views and large resources in the background. Release them when the app enters the background, and rebuild on return.</li>
<li>Leaks. Use Instruments' Leaks template. A retain cycle around a model session is a common cause of a process that never gets smaller.</li>
<li>Image size. Transform images with Image I/O before they enter the model.</li>
</ul>
<p>For the warnings, Apple gives three points. Don't search your whole object graph for memory to release, because iOS compresses pages the app hasn't touched recently. If your data is easy to recreate, use NSPurgeableData. Apple advises against combining NSCache with NSPurgeableData.</p>
<p>For context, Apple's WWDC26 session says developers should query the model rather than assume a size. It introduced APIs in iOS 26.4 for reading contextSize and counting tokens. The sample prints 8192. Other coverage gave 4K, so measure on the device you run on. The session does not say what happens when a session exceeds the context limit.</p>
<h2>Android: the killer goes after the background first</h2>
<p>Android's low-memory killer terminates processes by oom_adj_score. Cached processes and processes with a service and no UI go first. Focus on onTrimMemory levels TRIM_MEMORY_UI_HIDDEN and TRIM_MEMORY_BACKGROUND. Since Android 14 the system no longer sends the other legacy levels.</p>
<p>Google's Gemini Nano page says AICore manages the model and that it doesn't affect your app's disk and runtime memory budget. It does not say where a long session's KV cache lives. Measure that on a device.</p>
<h2>A plan for the next release</h2>
<ol>
<li>Reproduce it. Run a scripted conversation for an hour on the oldest device you support. Log memory and context length each turn.</li>
<li>Read the jetsam report after each crash. Record reason, lifetimeMax and largestProcess.</li>
<li>Bound the state. Set a context budget from the measured curve, and summarize or drop older turns past it.</li>
<li>Respond to warnings. Drop caches and offload cold context. Release the model when backgrounded, and measure the reload cost.</li>
<li>Consider offload. mzCache and KVSwap show that moving KV entries out of RAM can work on mobile-class hardware, with a latency cost.</li>
<li>Test again. Keep the one-hour soak test in CI for release candidates.</li>
</ol>
<p>The crash at minute sixty is almost never one bad allocation. It is state that grows with the conversation, meeting a limit the operating system sets, and the operating system writes you a file that says why.</p>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>Sources</h2>
<ul>
<li><a href="https://developer.apple.com/documentation/xcode/identifying-high-memory-use-with-jetsam-event-reports">Identifying high-memory use with jetsam event reports</a> (Apple)</li>
<li><a href="https://developer.apple.com/documentation/xcode/responding-to-low-memory-warnings">Responding to low-memory warnings</a> (Apple)</li>
<li><a href="https://developer.apple.com/documentation/xcode/making-changes-to-reduce-memory-use">Making changes to reduce memory use</a> (Apple)</li>
<li><a href="https://developer.apple.com/videos/play/wwdc2026/241/">WWDC26: What's new in the Foundation Models framework</a> (Apple)</li>
<li><a href="https://developer.android.com/topic/performance/memory/manage-app-memory">Manage your app's memory</a> (Android)</li>
<li><a href="https://developer.android.com/ai/gemini-nano">Gemini Nano on Android</a> (Android)</li>
<li><a href="https://arxiv.org/abs/2609.01338">mzCache: On-Device LLM Memory Management under Multitasking</a></li>
<li><a href="https://arxiv.org/html/2511.11907v2">KVSwap: Disk-aware KV Cache Offloading for Long-Context On-device Inference</a></li>
</ul>
<h2>What I could not verify</h2>
<ul>
<li>A fixed per-app memory limit per iPhone model. Single developer reports cite about 6 GB on App Store builds and about 15 GB on development builds.</li>
<li>Whether the Foundation Models KV cache counts against the app's process.</li>
<li>What happens when a Foundation Models session exceeds its context window.</li>
<li>Where Gemini Nano's per-session state lives.</li>
<li>The mzCache results on other devices. The paper tests two phones and two small models.</li>
<li>The KVSwap numbers on a phone. They come from a Jetson Orin AGX.</li>
<li>The one-hour timing in general. I found no source that measures it on phones.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Your Phone Summarizes Notifications. Here's the Path One Takes Before You See It.]]></title><description><![CDATA[What a notification passes through before a phone shows a summary: payload limits, the extension time budget, and where an on-device model fits.]]></description><link>https://sairamkotha.hashnode.dev/your-phone-summarizes-notifications-here-s-the-path-one-takes-before-you-see-it</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/your-phone-summarizes-notifications-here-s-the-path-one-takes-before-you-see-it</guid><category><![CDATA[AI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Fri, 23 Oct 2026 05:18:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/c22f59f0-dc99-4376-bee5-da7e806c11ed.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A notification arrives, and a one-line summary appears above it. The phone did a model call before it showed you anything. That call has to fit inside the same delivery path the notification already used, and that path has hard limits on size and time.</p>
<p>This article follows one notification from the server to the screen, marks where an on-device model can sit in that path, and lists the limits that decide whether the summary is ready when you look.</p>
<h2>The path a notification takes</h2>
<ol>
<li><strong>Your server sends a push.</strong> The payload goes to Apple Push Notification service (APNs) or Firebase Cloud Messaging (FCM). Apple's documentation caps a standard remote notification at 4 KB (4,096 bytes) for the whole JSON payload. Title, body, custom data and deep links all count. Non-Latin text takes more bytes per character, so the same message can fit in English and fail in Hindi.</li>
<li><strong>The push service delivers it.</strong> Delivery is not instant or guaranteed. The device may be asleep, in low-power mode, or offline. The notification waits in the service until the phone can receive it.</li>
<li><strong>The phone may run your code first.</strong> On iOS, a Notification Service Extension runs only when the payload has an alert and sets <code>mutable-content</code> to 1. Silent pushes and badge-only pushes skip it. The extension has a short time budget. Apple's sample code handles <code>serviceExtensionTimeWillExpire()</code>, which is the last chance to return content before the system stops the extension. I could not confirm the exact seconds from Apple's pages, so treat any specific number as unverified until you test it.</li>
<li><strong>The system shows the notification.</strong> If there is no extension, or the extension runs out of time, the original content is shown.</li>
</ol>
<p>Only the first three steps are about your app. The summary arrives at step 3 or 4, and only if the phone decides to make one.</p>
<h2>Where the model sits</h2>
<p>Apple Intelligence notification summaries need an iPhone 16 family model, iPhone 15 Pro, or iPhone 15 Pro Max on iOS 18.1 or later, with Apple Intelligence turned on, according to Apple's support page. Press coverage says the summaries work offline, which implies on-device processing. The Apple page I could reach did not show that sentence, so I am not quoting Apple on it.</p>
<p>I also could not confirm which step runs the summary: the extension, the system after delivery, or a separate pass. Apple's public docs for third-party developers do not say that a third-party extension can call the summarizer. If you are building an app that needs its own on-device model in this path, assume you cannot rely on Apple's summaries and run your own model under your own budget.</p>
<p>Press coverage in January 2025 reported that summaries were paused for News and Entertainment apps after accuracy complaints. The behavior may have changed since, so check the current iOS release notes before you design around it.</p>
<h2>The budget that matters</h2>
<p>For a model call inside a notification, three budgets compete:</p>
<ul>
<li><strong>Payload size.</strong> 4 KB for the whole push, before the device does anything. A summary cannot be sent in the push if the source text is already near the cap. Send an ID and fetch the body in the extension if you need more text, and budget the fetch against the time limit.</li>
<li><strong>Time.</strong> The extension is short-lived. A slow network call followed by a model call can use up the budget before the summary is ready. The safe pattern is to return the original text first and update only if the model finishes in time.</li>
<li><strong>Context window.</strong> The on-device model has a fixed token window. My earlier article on Apple's 4,096-token window shows how instructions, tools and output all spend the same room. A notification body is small, but a thread history or a long bundle of messages is not.</li>
</ul>
<h2>What to do with this</h2>
<ul>
<li>Keep the push payload small, and put the full content behind an ID.</li>
<li>Return the best content you have before the time limit. Do not wait for a model to finish.</li>
<li>Test on a real device with Apple Intelligence on and off, and on a low-power setting. Simulator timing is not the same.</li>
<li>Do not assume a summary exists. The user may have it turned off, the device may not qualify, or the app may be excluded.</li>
</ul>
<p>The notification looks like the simplest part of a phone. It is a small, time-limited pipeline with a model at one optional step, and the limits in that pipeline decide what you read first.</p>
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
<h2>What I could not verify</h2>
<ul>
<li>The exact time budget for a Notification Service Extension. Third-party guides say 30 seconds. I could not fetch Apple's page with the number.</li>
<li>Which step in the pipeline runs the summary, and whether a third-party extension can call it.</li>
<li>The exact Apple sentence on on-device processing for notification summaries.</li>
<li>Current status of the News and Entertainment exclusion after iOS 18.3.</li>
</ul>
<p>#AI #iOS #Android #OnDeviceAI #MobileEngineering</p>
]]></content:encoded></item><item><title><![CDATA[What Happens When You Tap "Sign in with Google" or a Passkey]]></title><description><![CDATA[What a passkey stores, what crosses the network during sign-in, and why sign-in can fail after a phone change.]]></description><link>https://sairamkotha.hashnode.dev/what-happens-when-you-tap-sign-in-with-google-or-a-passkey</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/what-happens-when-you-tap-sign-in-with-google-or-a-passkey</guid><category><![CDATA[passkeys]]></category><category><![CDATA[Security]]></category><category><![CDATA[Android]]></category><category><![CDATA[iOS]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Thu, 22 Oct 2026 04:48:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/78443592-0e98-4681-84c3-ea00144a4a50.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You tap a button, your face unlocks the phone, and you're in. No password was typed, and none crossed the network. Here's what happened.</p>
<h2>Two different sign-ins</h2>
<p>"Sign in with Google" and a passkey are not the same thing, and most people mix them up.</p>
<ul>
<li><strong>Sign in with Google</strong> is a redirect. The app sends you to Google, Google checks who you are, and Google gives the app a signed ID token. The token is a signed JWT whose <code>sub</code> claim is a stable ID for you at Google, scoped to that app. It says who signed in only as that opaque ID. Name and email appear only if the app asked for them through scopes. The app never sees your Google password. Your Google account may itself use a passkey, but the basic ID token doesn't say which sign-in method you used.</li>
<li><strong>A passkey</strong> is a key pair made for one site. The private key stays on your device. The site stores only the public key. To sign in, the site sends a random challenge, your phone signs it after you unlock it, and the site checks the signature with the public key.</li>
</ul>
<h2>What the device stores</h2>
<p>For each passkey, the device holds a private key. The app never gets a copy. The key is meant to sit in hardware-backed storage, but I could not confirm which hardware Apple or Android uses for passkey keys. See the last section.</p>
<p>The signature is bound to the site's domain. A fake site at a look-alike domain gets a signature that doesn't match, so the sign-in fails. That is why passkeys resist phishing in a way passwords don't.</p>
<h2>How it reaches your other devices</h2>
<p>A passkey on one phone is useless on the next unless it syncs. Synced passkeys are stored by:</p>
<ul>
<li><strong>iCloud Keychain</strong> on Apple devices</li>
<li><strong>Google Password Manager</strong> on Android and Chrome</li>
<li>third-party managers such as 1Password or Bitwarden</li>
</ul>
<p>Apple's and Google's stores don't copy into each other. A passkey saved in iCloud Keychain won't appear in Google Password Manager, and the reverse holds too. Cross-ecosystem sign-in still works in one case: a site or a new computer can show a QR code, and your phone's passkey signs the challenge over Bluetooth. The key is never copied. Hardware security keys stay bound to the physical key and don't sync.</p>
<h2>Why sign-in fails after a phone change</h2>
<p>Usually it's one of these:</p>
<ol>
<li>The passkey was never synced. It lived only on the old phone.</li>
<li>The new phone is in a different ecosystem, so the iCloud and Google stores don't share.</li>
<li>Cloud sync was off on the old phone, or the account was signed out.</li>
<li>The site only registered a passkey on the old device.</li>
</ol>
<h2>What to do before you switch phones</h2>
<ul>
<li>Check that passkeys are synced. On iPhone: Settings → Passwords. On Android: Google Password Manager.</li>
<li>Sign in to important accounts on the new phone before you hand back the old one.</li>
<li>Keep a second sign-in method, such as an authenticator app or recovery codes, stored outside the phone.</li>
<li>After a move, register a new passkey on the new device if the site offers the option.</li>
</ul>
<p>Passkeys are safer than passwords, but they are tied to where the key lives. Knowing where that is decides whether a new phone is a five-minute setup or a locked account.</p>
<h2>What I could not verify</h2>
<p><strong>Confirmed against primary sources (checked 2026-10-22):</strong></p>
<ul>
<li><strong>iCloud Keychain sync.</strong> Apple's Platform Security guide ("iCloud Keychain security overview") says passkeys sync across iPad, iPhone, Mac, Apple Watch, and Apple Vision Pro, and that they are "encrypted end-to-end so that Apple and other devices can't read their contents."</li>
<li><strong>Apple new-device join.</strong> Apple's "Use passkeys in iPhone and iPad" support page says passkeys sync through iCloud Keychain and are stored in the Passwords app. A new device joins the syncing circle by pairing with an existing iCloud Keychain device, or through keychain recovery. Recovery needs the Apple Account password and a verification code.</li>
<li><strong>Google Password Manager PIN.</strong> Google's Chrome help page (support.google.com/chrome/answer/16608973) says a Google Password Manager PIN is created when you first create a passkey, and that it helps you "use your passkeys on a new device." Its stated purpose includes: "Make sure that no one, not even Google, can access your encrypted data." The page does not say "end-to-end."</li>
<li><strong>Google sync scope.</strong> Passkey sync across Windows, macOS, Linux, and Android is from 2024 press coverage (The Hacker News, BleepingComputer). I did not retrieve Google's own page for it.</li>
<li><strong>OIDC ID token (OpenID Connect Core 1.0, section 2).</strong> <code>iss</code>, <code>sub</code>, <code>aud</code>, <code>exp</code>, and <code>iat</code> are REQUIRED. <code>aud</code> MUST contain the client's <code>client_id</code>. The ID token MUST be a JWS-signed JWT. <code>nonce</code> must be verified when the request sent one. The spec defines no password claim. Name and email come from scope-based standard claims in section 5.1, which I did not read.</li>
<li><strong>Passkey challenge-response.</strong> The site-stores-public-key, phone-signs-challenge model matches the WebAuthn flow described above. I did not fetch the WebAuthn spec itself.</li>
</ul>
<p><strong>Still unverified:</strong></p>
<ul>
<li><strong>Secure Enclave for passkey keys.</strong> Apple's Secure Enclave page says key material is protected in hardware. It does not say passkey private keys live there. The passkey pages I read don't say it either. Treat the Secure Enclave claim as unconfirmed.</li>
<li><strong>Android secure element or TPM.</strong> Not checked.</li>
<li><strong>Hardware security keys don't sync.</strong> Not checked against a FIDO source.</li>
<li><strong>Settings paths.</strong> Apple's support page gives no menu path. The "Settings → Passwords" path is from third-party guides. The Google Chrome path (More → Passwords and autofill → Google Password Manager) is from Google's help page.</li>
<li><strong>Google PIN as the only new-device factor.</strong> Google's help page names the PIN. Android screen lock as an alternative comes from 2024 press coverage. Google's Security Blog (2022) was not readable, and Google's "Passkeys" support page returned 404.</li>
<li><strong>Passkeys in Google Password Manager on iPhone.</strong> Press coverage (heise) says iOS support is there. I did not confirm it on Google's own page.</li>
<li><strong>New-device phishing risk.</strong> How each platform's new-device step can be phished has not been tested on current phones.</li>
</ul>
<p>#Passkeys #Security #Android #iOS</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[Gemini Nano 4 Is in Preview. Your Prompt Budget Still Has to Fit.]]></title><description><![CDATA[Gemini Nano 4 is in preview, but the Prompt API still caps input under 4,000 tokens. How to count, budget and plan for model states.]]></description><link>https://sairamkotha.hashnode.dev/gemini-nano-4-is-in-preview-your-prompt-budget-still-has-to-fit</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/gemini-nano-4-is-in-preview-your-prompt-budget-still-has-to-fit</guid><category><![CDATA[Android]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Kotlin]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Mon, 19 Oct 2026 12:02:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/aeb70def-bb43-4746-8a16-9f0195ad1694.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Google's on-device model for Android is free to call and runs without a network round trip. The limits are in the docs: input must be under 4,000 tokens, and Google asks developers to avoid output over about 4,000 tokens. Nano 4 is now in developer preview, and it does not remove either limit. If your feature sends a long document or a long chat history, the limit arrives before your users notice a slow screen.</p>
<p>This article covers what Google's documentation says, what changed at I/O 2026, and how to budget a prompt before you call it.</p>
<p>How to read it: the Prompt API limits come from Google's page "Get started with Prompt API" (developers.google.com/ml-kit/genai/prompt/android/get-started), read on October 19, 2026. The I/O 2026 changes come from Google's post "Top AI on Android updates for building intelligent experiences" (May 26, 2026). The budget example uses sizes I assumed, and the code is a sketch I have not compiled or run on a device.</p>
<h2>The numbers Google gives</h2>
<ul>
<li><strong>Input.</strong> "Input must be under 4000 tokens (or approximately 3000 English words)."</li>
<li><strong>Output.</strong> The page gives no hard number. It says: "Use cases that require long output (more than 4K tokens) should be avoided."</li>
<li><strong>Output cap you set.</strong> <code>maxOutputTokens</code> "defines the maximum number of tokens that can be generated in the response." The page gives no default or range.</li>
<li><strong>Counting.</strong> The page points to a <code>countTokens</code> reference for measuring input.</li>
</ul>
<p>The page does not say what a token is for this model, or what happens when input reaches the limit. Measure your own strings instead of relying on a characters-per-token rule.</p>
<h2>What the device needs</h2>
<ul>
<li><strong>Android API level 26 or higher.</strong></li>
<li><strong>Not an unlocked bootloader.</strong> The page says the API "is not supported on devices with an unlocked bootloader."</li>
<li><strong>AICore.</strong> Gemini Nano runs in Android's AICore system service, which also manages downloads and updates. The Prompt API page lists model states that your app must handle: AVAILABLE, DOWNLOADABLE, DOWNLOADING and UNAVAILABLE. A model that is not ready yet is a normal state.</li>
<li><strong>Per-app quota.</strong> "AICore enforces an inference quota per app." The page gives no numbers, so you cannot plan for a specific request count.</li>
</ul>
<h2>What changed at I/O 2026</h2>
<p>Google's I/O post adds three things, and each has a status you should check before you build on it:</p>
<ul>
<li><strong>Gemini Nano 4 is in developer preview.</strong> Google says developers "can already preview and prototype" with Nano 4 through the AICore developer preview. It is "launching in flagship devices later this year." The post names no devices and no date.</li>
<li><strong>Prefix caching.</strong> The post says it "reduces inference time by storing and reusing the intermediate LLM state of processing a shared and recurring part of the prompt." The Prompt API docs list prefix caching as Experimental. It helps most when many requests share a long system prompt.</li>
<li><strong>Structured Output.</strong> Google calls it "upcoming," with no date. The Prompt API docs label "Generate structured output" as Alpha.</li>
</ul>
<p>The same post names Firebase AI Logic's hybrid inference as a way to route between on-device and cloud models. Its modes include <code>PREFER_ON_DEVICE</code> and <code>ONLY_ON_DEVICE</code>. The post does not say whether the modes are stable.</p>
<p>The docs also mark "Prompt," "Set system instructions" and "Use thinking mode" as Beta. Build your launch plan around the parts that are stable, and check the labels before each release.</p>
<h2>A budget for a prompt</h2>
<p>These sizes are my assumptions, chosen to show the shape, not measurements:</p>
<ul>
<li>System instructions: 300 tokens.</li>
<li>A user message with a pasted article: 2,400 tokens.</li>
<li>Room for the reply: 800 tokens, set with <code>maxOutputTokens</code>.</li>
</ul>
<p>That leaves about 500 tokens of margin under the 4,000-token input limit. A second turn with a long history can exceed it. The planning rule is simple: count the prompt before the call. If it does not fit, shorten the input, summarize older turns, or split the task.</p>
<pre><code class="language-kotlin">// Sketch, not compiled. Check the current ML Kit Prompt API reference for exact names.
val inputTokens = /* count with the countTokens reference */
if (inputTokens &gt;= 4000) {
    // trim the oldest turns, summarize the document, or split the task
}
</code></pre>
<h2>A fallback when the model is not there</h2>
<p>A feature that depends on the model must still work when the model is unavailable, unsupported or still downloading. Google's options are:</p>
<ol>
<li><strong>Show a plain state.</strong> Tell the user the feature is preparing, and keep the rest of the app usable.</li>
<li><strong>Route to cloud.</strong> Firebase AI Logic's hybrid inference can send requests to a cloud Gemini model when Nano is unavailable. Check the current mode names and status in its docs before you ship.</li>
<li><strong>Turn the feature off.</strong> For a feature that needs on-device data, that may be the right answer.</li>
</ol>
<p>Pick one before launch, not after the first crash report.</p>
<h2>What I would do</h2>
<p>This section is my opinion, not Google's.</p>
<ol>
<li><strong>Count before you call.</strong> Do not learn the input was too long from an error.</li>
<li><strong>Keep history short.</strong> Summarize older turns rather than sending the whole conversation.</li>
<li><strong>Set <code>maxOutputTokens</code>.</strong> Keep replies inside the output budget the page recommends.</li>
<li><strong>Handle the model states.</strong> A DOWNLOADABLE or DOWNLOADING model is normal, not a crash.</li>
<li><strong>Measure prefix caching yourself.</strong> It is Experimental, and Google gives no number for the gain.</li>
<li><strong>Test on a range of devices.</strong> Quota, availability and warmup vary, and Google gives no figures for them.</li>
</ol>
<h2>What I could not verify</h2>
<ul>
<li><strong>Exact token counts.</strong> I did not run <code>countTokens</code> on real strings, so I cannot say how many tokens a given paragraph is.</li>
<li><strong>Output behavior past the limit.</strong> The page advises against long output but gives no hard number and no error behavior.</li>
<li><strong>Quota numbers.</strong> The page says there is a per-app quota and gives no figures.</li>
<li><strong>Nano 4 devices.</strong> Two sources I checked list different phones, and neither list is on a Google page I could read. I have not named any device.</li>
<li><strong>Prefix caching gains and Structured Output dates.</strong> Google gives no numbers or dates.</li>
</ul>
<p>Sources, accessed October 19, 2026: Google for Developers, "Get started with Prompt API" (ML Kit GenAI), developers.google.com/ml-kit/genai/prompt/android/get-started; Google for Developers Android Blog, "Top AI on Android updates for building intelligent experiences" (May 26, 2026), developer.android.com/blog/posts/top-ai-on-android-updates-for-building-intelligent-experiences-from-google-i-o-26; Android Developers, "Gemini Nano" AICore overview (last updated October 8, 2026), developer.android.com/ml/aicore.</p>
<p>If your Android feature needs a 10-page document in one prompt, what does it do on a device where Gemini Nano is still downloading?</p>
<hr />
<p>Work with me: I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: topmate.io/sairamkotha</p>
]]></content:encoded></item><item><title><![CDATA[Claude Writes 90% of Claude Code. Here's Who Still Approves the Merge.]]></title><description><![CDATA[How the Claude Code team reviews code written by Claude: layers of checks, approval scaled to risk, and what is still unsolved.]]></description><link>https://sairamkotha.hashnode.dev/claude-code-team-code-review-who-approves</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/claude-code-team-code-review-who-approves</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[code review]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[ai agents]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sun, 18 Oct 2026 04:01:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/1f907647-e408-48d5-8e6a-5514a47f62fc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The Claude Code team says Claude writes about 90 percent of its own code. Changes arrive faster than people can read them line by line. So the team stopped asking "did a human read this?" and started asking a better question: what could go wrong with this change, and which check should catch it?</p>
<p>Their answer is a stack of checks, with a person accountable for every change that ships. This article walks through the layers, the numbers they published, and what they still have not solved.</p>
<p>How to read it: the post is "How the Claude Code team does code review," published October 16, 2026, at claude.dev/blog/solving-code-review-at-anthropic, read on October 18, 2026. Every number below is the team's own, from internal measurements, and I have not checked them independently. I have not used their system. Where I add an opinion, I label it.</p>
<h2>The problem they named</h2>
<p>Merged pull requests more than doubled from June to September, and the share needing a revert fell by more than 40 percent. The stated goal is to minimize human review time without increasing reverts or incidents. Those two numbers together are the real claim: faster merging with fewer rollbacks, not just faster merging.</p>
<p>The reframing matters more than any tool. "Did a human read the code?" is a poor proxy for safety. The team moved review from a gate at the end of a process to something that runs continuously, which they describe as "more like spellcheck while you write."</p>
<h2>The layers</h2>
<ol>
<li><strong>Verify before the pull request.</strong> Claude runs tests, builds and linters, and shows the output rather than claiming success. A subagent reviews the diff in a fresh context. Recurring findings are fixed at the source, in <code>CLAUDE.md</code>, a <code>REVIEW.md</code> written by code owners, skills, or lint rules.</li>
<li><strong>An AI reviewer on every pull request.</strong> Parallel agents hunt for bugs. A separate group votes on each finding, and only the findings that survive are posted, labeled Important, Nit, or Pre-existing. The team reports that internal pull requests getting substantive comments rose from 16 percent to 54 percent when the tool launched.</li>
<li><strong>A security review that can block.</strong> Open bug or security findings stop a merge until they are fixed or closed with a reason.</li>
<li><strong>Required continuous integration.</strong> Lint, types, tests across platforms, and builds. The pull request template asks how the change was tested and how to revert it.</li>
<li><strong>Human approval scaled to risk.</strong> A classifier sets the tier from the change, not the author. Low risk needs no human once automated checks pass. Medium needs one person, and high needs two. Some paths, such as CI workflow files, always need a person.</li>
<li><strong>A merge queue, flags and gradual release.</strong> The queue re-runs checks against main. New features reach internal users first, and releases go to a <code>latest</code> channel before <code>stable</code>.</li>
<li><strong>Fast detection and rollback.</strong> Flags turn a feature off without a deploy, and a weekly on-call rotation watches for problems.</li>
</ol>
<p>Two rules in the list are easy to skim past. An agent's approval never counts as a human approval. And an approval covers one specific commit, so a later change needs a new review.</p>
<h2>The reviewer needed its own fixing</h2>
<p>The team found that the AI reviewer could fail to converge. One command-line pull request went through more than fifty review rounds overnight. About 59 percent of what the reviewer kept flagging was code that had been there since its first pass.</p>
<p>Their fixes were practical: cap the review at three rounds, re-review only the diff after the first few passes, flag bug classes rather than single lines, and never repeat a finding. Their numbers before and after:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Pass after one review-and-fix round</td>
<td>65%</td>
<td>78%</td>
</tr>
<tr>
<td>Needed five or more rounds</td>
<td>8.9%</td>
<td>1.5%</td>
</tr>
<tr>
<td>Mean reviews to pass</td>
<td>2.08</td>
<td>1.32</td>
</tr>
</tbody></table>
<p>They also turn past incidents into a test set: bug-causing pull requests, their reverts and their fixes become cases the reviewer runs daily.</p>
<h2>Where humans stay</h2>
<p>The post lists what stays with people:</p>
<ul>
<li>Whether to build something at all: direction, scope and taste.</li>
<li>Risky code, such as billing, the auto-updater and CI workflows.</li>
<li>Architecture that others will copy.</li>
<li>Reading evidence, such as test output, screenshots and findings.</li>
<li>Owning outcomes: "A person is accountable for every change that ships, whoever typed it."</li>
</ul>
<h2>What is still unsolved</h2>
<p>The post is direct about the gaps, and I think this is the most useful part for other teams.</p>
<ul>
<li><strong>No circuit breakers yet.</strong> The team has no mechanism that tightens approval when reverts or incidents rise. Engineers want one before more code moves to zero-human merges.</li>
<li><strong>Bad merges are still caught by people.</strong> Detection depends on the author and on-call, as well as dashboards.</li>
<li><strong>Slowing down is not the fix.</strong> The post says slowing down lowers error rates, "mostly because you're doing less of everything." They prefer getting faster at detection and repair.</li>
<li><strong>Reviewer loops have no limit.</strong> Agent-author and agent-reviewer loops can run indefinitely without a convergence cap.</li>
</ul>
<h2>What I would take from it</h2>
<p>This section is my opinion, not the team's.</p>
<ol>
<li><strong>Replace "who read it?" with "which check catches this risk?"</strong> Then write down the check for each risk class.</li>
<li><strong>Scale approval to risk.</strong> Most changes do not need two people. A few paths always do.</li>
<li><strong>Cap every automated review loop.</strong> Three rounds is a number they chose; the principle is a hard limit.</li>
<li><strong>Turn incidents into tests.</strong> Every revert should leave behind a case the reviewer runs.</li>
<li><strong>Build the circuit breaker before you need it.</strong> The team says it doesn't have one yet.</li>
</ol>
<h2>What I could not verify</h2>
<ul>
<li><strong>Every number.</strong> The 90 percent, the 16-to-54 percent, and the convergence table are internal figures, and I cannot check them.</li>
<li><strong>Transfer to other teams.</strong> Claude Code is one product with one codebase, and the team's tooling is its own.</li>
<li><strong>Current state.</strong> The post describes one moment in October 2026, and the tooling is changing.</li>
</ul>
<p>Source, accessed October 18, 2026: "How the Claude Code team does code review," claude.dev/blog/solving-code-review-at-anthropic (October 16, 2026).</p>
<p>If your team's reviewers approved the last ten changes in under a minute each, which one would you have checked yourself?</p>
<p>#AI #ClaudeCode #CodeReview #SoftwareEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[AI Writes the Code Now. The Engineer's Job Is Knowing What to Refuse.]]></title><description><![CDATA[Reviewers now judge intent, not every line. What the engineer's job becomes when an AI agent writes the code, with sources from Anthropic and Medium.]]></description><link>https://sairamkotha.hashnode.dev/ai-writes-the-code-engineer-job-knowing-what-to-refuse</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/ai-writes-the-code-engineer-job-knowing-what-to-refuse</guid><category><![CDATA[AI]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[code review]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[engineering leadership]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Sat, 17 Oct 2026 15:46:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/16df7074-8d45-48d1-819c-3639b7a89fb7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A developer on Medium recently wrote that AI made him five times as productive, so he took on five times as much work. Within weeks he had stacks of pull requests and no real control over what shipped. His title for the problem was the question everyone is now asking: what should programmers do while the AI writes the code?</p>
<p>The answer is not to write it faster. Anthropic's engineering team describes the shift from the inside: when code changes arrive at 30,000 a day, the engineer's job moves from reading every line to judging what a change is for, and to telling the agent "no" when it should not proceed. This article takes that seriously and asks what it means for the people who review and own a codebase.</p>
<p>How to read it: the Medium essay is by Scott Batson, "What Should Programmers Do While the AI Writes Code?", read on October 17, 2026. The Anthropic description comes from its engineering post "Redesigning source control at Anthropic" (October 13, 2026), read on October 14. I have not used Anthropic's internal system and have no inside knowledge. The claims about review practice are the post's own, and I label my opinions.</p>
<h2>The new problem is volume, not writing</h2>
<p>Scott's essay names the trap precisely. AI made each task faster, so he took on more tasks, and "I was working on everything, everywhere, all at once." His productivity rose and his control fell. The code was not the bottleneck anymore. Review and judgment were.</p>
<p>Anthropic's post makes the same point at a larger scale. It says merges went from around 2,500 a day in May 2026 to more than 30,000 a day in September, and that source control became the biggest bottleneck. It also says that Anthropic could not replicate the industry-standard global lock on the merge queue at that volume, so it invested in automated testing after a merge and before a deploy instead.</p>
<h2>Reviewers now judge intent</h2>
<p>The post describes what reviewers do when they cannot read every line. Reviewers "examine the intent of a change instead of reading every line," and the review tool can express complex rules about who must approve what.</p>
<p>Read the wording carefully. It does not say reviewers stopped caring about the code. It says the question moved from "is every line right?" to "is this change doing what it claims, and does the test suite say so?"</p>
<h2>The engineer's job became saying no</h2>
<p>The post's most direct sentence is about the engineer's role: the engineers' job was to tell Claude "no." It gives two examples: several systems that tried to brute-force when a change had merged to main, and a review that was too defensive about trusting another internal service.</p>
<p>That is a job description for a senior engineer. Anyone can accept an agent's output. The valuable skill is refusing the wrong approach, and knowing which rule the agent should not break.</p>
<h2>What this means for a team that is not Anthropic</h2>
<p>This section is my opinion, not Anthropic's.</p>
<ol>
<li><strong>Write down what "done" means.</strong> An agent cannot judge intent it was never given. A written success criterion, such as a failing test that must pass, is the reviewer's first tool.</li>
<li><strong>Review intent, then spot-check.</strong> If a change cannot be explained in two sentences, send it back before reading the diff.</li>
<li><strong>Protect one human veto.</strong> The post's examples are about a human saying no to a bad approach. Keep that role explicit in your process, not implied.</li>
<li><strong>Measure the queue, not the output.</strong> Scott's warning was about stacking work. Track how many changes are waiting on a human, not just how many the agent produced.</li>
</ol>
<h2>What I could not verify</h2>
<ul>
<li><strong>Anthropic's internal review practice.</strong> I read one post. I cannot say how reviewers work day to day, and the post describes intent-based review at a company with unusual volume.</li>
<li><strong>Scott's productivity figure.</strong> "Five times as productive" is his own estimate, not a measurement I checked.</li>
<li><strong>Generalizing to your team.</strong> The 30,000-merges figure comes from a company with a very large monorepo. A small team has different constraints.</li>
</ul>
<p>Sources, accessed October 14 and 17, 2026: Anthropic Engineering, "Redesigning source control at Anthropic" (anthropic.com/engineering/redesigning-source-control); Scott Batson, "What Should Programmers Do While the AI Writes Code?" (Medium, August 31, 2026).</p>
<h2>A review I did this year</h2>
<p>On a trading app's real-time connection layer, an AI agent had made a change to how a live data socket is kept alive. I did not stop at the diff. I asked what the change does when the app goes to the background and comes back.</p>
<p>That question led to a regression in the foreground path. When the app returned to the foreground, a keep-alive setting re-enabled the socket in the middle of a reconnect, so the socket could reopen while the reconnect was still settling. The fix was a rule: a pending disable must win over a keep-alive that arrives during the same reconnect. It came with tests for that exact order of events.</p>
<p>Then an external review posted 16 findings on the pull request. The temptation was to accept the list. Instead I checked the open items against the current code. Six of seven were valid, and one was overstated. I also declined two findings outright. Only the valid ones went into the change.</p>
<p>The engineer's job in that review was not to write the fix. It was to decide what the change was supposed to do, and to refuse the findings that did not hold.</p>
<p>If your team merged ten times more code next quarter, which review would you stop doing first, and which would you never give up?</p>
<p>#AI #SoftwareEngineering #CodeReview #Engineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[Xcode 27 Moved SimulatorKit, and Taps Can Fail While Screenshots Still Work]]></title><description><![CDATA[Xcode 27 replaced Simulator.app with Device Hub and moved SimulatorKit.framework. What breaks, and how to harden simulator steps in CI.]]></description><link>https://sairamkotha.hashnode.dev/xcode-27-simulatorkit-ci-paths</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/xcode-27-simulatorkit-ci-paths</guid><category><![CDATA[iOS]]></category><category><![CDATA[Xcode]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[ci]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Fri, 16 Oct 2026 17:31:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/7c9c4b80-0f16-4845-873e-7b6dbaeab82d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Xcode 27 replaced Simulator.app with Device Hub and moved a private framework that automation tools load. The result is a split failure: some simulator calls still work, and the ones that send input can fail. If your CI drives the simulator, the failure can look like a flaky test rather than a broken path.</p>
<p>This article covers what changed, what breaks, and what I would change in a CI pipeline. I have not run any of this on Xcode 27 myself.</p>
<p>How to read it: the path change, the failing operations and the workarounds come from GitHub issues and a pull request in open-source simulator tools, read on October 16, 2026. I did not watch the video this article is based on, and I could not open its transcript, so I have not used it as a source. Where the sources disagreed, I say so.</p>
<h2>What changed</h2>
<p>Two things moved.</p>
<ul>
<li><strong>The app.</strong> Simulator.app is gone. Device Hub replaced it. One reporter found <code>Contents/Applications/DeviceHub.app</code> present and <code>Contents/Applications/Simulator.app</code> absent.</li>
<li><strong>The framework.</strong> Tools load <code>SimulatorKit.framework</code> to talk to simulators. In Xcode 26 it lived under the developer directory. In Xcode 27 it moved to a sibling folder:</li>
</ul>
<pre><code>Xcode 26:  Xcode.app/Contents/Developer/Library/PrivateFrameworks/SimulatorKit.framework
Xcode 27:  Xcode.app/Contents/SharedFrameworks/SimulatorKit.framework
</code></pre>
<p>A pull request that patched one tool describes the new location as "a sibling of <code>Developer</code>, not a relocation within it." That detail matters for any path logic that assumes everything sits under <code>Developer</code>.</p>
<h2>What breaks</h2>
<p>The reports agree on this, with one difference noted below.</p>
<ul>
<li><strong>Attaching to a simulator</strong> fails with a framework-load error when the tool looks in the old location: <code>Attempting to load a file at path '/Applications/Xcode 27.app/Contents/Developer/Library/PrivateFrameworks/SimulatorKit.framework', but it does not exist</code>.</li>
<li><strong>Input fails.</strong> The same pull request says every simulator HID call (<code>tap</code>, <code>type</code>, <code>swipe</code>, <code>press</code>) fails when the framework cannot be loaded.</li>
<li><strong>Silent taps.</strong> One reporter saw <code>tap</code> and <code>button</code> return success while the screen did not change.</li>
<li><strong>Opening Simulator.</strong> <code>open -a Simulator</code> fails, because the app is gone.</li>
<li><strong>Builds.</strong> If you point <code>xcode-select</code> back at an older Xcode to get the old paths, builds that need the Xcode 27 SDK fail.</li>
</ul>
<p>The reports disagree on screenshots. The pull request says screenshots and app install are unaffected. Another report says video and screenshots gave a black screen and a crash after the path was patched. I would test screenshots on your own runner before trusting either account.</p>
<h2>Workarounds that people used</h2>
<ul>
<li><strong>Pin <code>xcode-select</code> to an older Xcode.</strong> This fixes attach and input, but breaks builds that need the Xcode 27 SDK. Good for a short fallback, not as a fix.</li>
<li><strong>Symlink the framework into the old layout.</strong> This works locally, but it writes into the Xcode bundle and needs sandbox changes in at least one tool. I would not put it in CI.</li>
<li><strong>Use a physical device.</strong> One reporter drove an iPhone over iPhone Mirroring instead.</li>
</ul>
<p>The pull request's approach is the one I would copy: resolve the framework from both layouts at runtime, and return the path it actually loaded, so the path logic cannot drift from the file that gets used.</p>
<h2>What this means for a CI pipeline</h2>
<p>This section is my opinion, not the sources'.</p>
<p>Our repository's workflows run on Linux. The Android Maestro job says iOS simulator coverage "needs a macOS runner and is a deliberate follow-up." I did not find a macOS runner in any workflow. The iOS Detox configuration is set up for an iPhone 16 simulator, so iOS simulator tests are most likely run locally or on a separate Mac. If your failing CI runs are on a Mac runner, the fixes below apply directly. If not, tell me where they run.</p>
<ol>
<li><strong>Pin the Xcode version explicitly on macOS runners.</strong> Do not take whatever Xcode the image defaults to. Log <code>xcodebuild -version</code> at the top of every job.</li>
<li><strong>Resolve SimulatorKit from both layouts, and fail fast.</strong> A missing framework should stop the job with a clear message, not time out a test run.</li>
<li><strong>Do not use symlinks or <code>xcode-select</code> swaps in CI.</strong> They pass one machine and fail on the next image update.</li>
<li><strong>Separate the failures.</strong> A test that fails on a tap is an infrastructure failure until proven otherwise. Make the simulator check a step of its own, so a broken path does not look like a broken screen.</li>
</ol>
<p>A resolver in shell, as an illustration and not compiled:</p>
<pre><code class="language-bash">XCODE_CONTENTS="$(dirname "$(xcode-select -p)")"   # .../Xcode.app/Contents
SIMKIT=""
for p in "$XCODE_CONTENTS/Developer/Library/PrivateFrameworks/SimulatorKit.framework" \
         "$XCODE_CONTENTS/SharedFrameworks/SimulatorKit.framework"; do
  [ -d "$p" ] &amp;&amp; SIMKIT="$p" &amp;&amp; break
done
[ -n "$SIMKIT" ] || { echo "SimulatorKit not found under $XCODE_CONTENTS"; exit 1; }
echo "SimulatorKit: $SIMKIT"
</code></pre>
<h2>What I could not verify</h2>
<ul>
<li>The video's content. I used its title only.</li>
<li>Detox's behavior on Xcode 27. I did not check whether Detox or its simulator helper uses SimulatorKit, so I cannot say whether our iOS suite is affected.</li>
<li>Exact paths on later builds. The reports do not say which Xcode 27 builds they were run on, and I found no Apple documentation for Device Hub.</li>
<li>Whether Apple intends SimulatorKit to stay private. Tools depend on it, so expect it to move again.</li>
</ul>
<p>Sources, accessed October 16, 2026: quern-dev/quern pull request #176 (SimulatorKit relocation); anthropics/claude-code issue #79991 (Xcode 27 Device Hub); stablyai/orca issue #17587; software-mansion/argent issue #406.</p>
<p>If your simulator tests went green last week and red this week, which step did they fail on: attach, tap, or the screenshot?</p>
<p>#iOS #Xcode #CI #SoftwareEngineering</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[4K, 8,192 or 32K? Apple's WWDC26 Sessions Give Three Different Context Numbers]]></title><description><![CDATA[Apple's WWDC26 sessions cite 4K, 8,192 and 32,000 tokens for Foundation Models. Why the numbers differ and how to budget tokens in your iOS app.]]></description><link>https://sairamkotha.hashnode.dev/apple-foundation-models-context-window-4k-8k-32k</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/apple-foundation-models-context-window-4k-8k-32k</guid><category><![CDATA[iOS]]></category><category><![CDATA[AI]]></category><category><![CDATA[Swift]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[llm]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Fri, 16 Oct 2026 03:14:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/990d7699-e1f9-4084-b83e-7253a13fa46f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Apple's own WWDC26 sessions give three different numbers for how much text the Foundation Models framework can hold: 4K for the on-device model in one session, 8192 in a code sample in another, and 32,000 for the new server model. If your app hardcodes any one of them, one of Apple's own sessions says you are wrong.</p>
<p>This follows up my last article on the on-device window. It covers what Apple said, why the numbers may differ, and how to budget tokens when one API can now talk to two models.</p>
<p>How to read it: every claim comes from Apple's WWDC26 sessions "What's new in the Foundation Models framework" (241) and "Build with the new Apple Foundation Model on Private Cloud Compute" (319), and from Apple's technote TN3193, all read on October 16, 2026. The code is a sketch I have not compiled, and I ran nothing on a device. Where Apple was silent, I say so.</p>
<h2>The three numbers</h2>
<ul>
<li><strong>4K.</strong> In session 319, comparing the two models, Apple says "The on-device model offers 4k, and with PCC you get 32K." My previous article's 4,096 came from TN3193.</li>
<li><strong>8192.</strong> In session 241, Apple's sample code for the new context-size API is <code>print(model.contextSize) // 8192</code>. That is a comment in a sample, not a stated spec.</li>
<li><strong>32,000.</strong> The Private Cloud Compute (PCC) model "has a 32,000 token context window".</li>
</ul>
<p>I can see two readings of 4K versus 8192, and Apple does not say which is right. One is that the new on-device model has a bigger window than the one in TN3193. The other is that the sample simply shows a device where the value is 8192. Session 241 hints at the second: it says to use the context-size and token-counting APIs, available since iOS 26.4, to "adapt your app to the hardware it's running on."</p>
<h2>What changed in the model</h2>
<p>Per session 241, the on-device model was "rebuilt from the ground up", with better logic and tool calling, and it gains vision. You attach an image to a prompt next to your text. Apple warns that "larger images will consume more tokens and incur more latency," so images spend the same window your text does.</p>
<h2>What the server model adds</h2>
<p>The PCC model is a much bigger model with the same session API. Per Apple:</p>
<ul>
<li><strong>One line to switch.</strong> Apple says changing one line of code switches a session to the server model. Generable output and tools work the same.</li>
<li><strong>Reasoning.</strong> It offers three levels: light, moderate and deep. Reasoning is extra text the model generates, and Apple says it "counts towards your context size limit." Deep reasoning text may be longer than the answer itself.</li>
<li><strong>Cost and limits.</strong> There are no token costs to the developer. Each user gets a daily limit, higher with iCloud+. Apple says it is available to apps with fewer than 2 million downloads and needs an entitlement. Session 319 words this as "less than 2M downloads" and session 241 as fewer than 2 million first-time downloads, so check the current terms.</li>
<li><strong>Trade-offs.</strong> On-device works offline and has no request limits. PCC needs a connection and has the daily limit. Both need an Apple Intelligence device.</li>
</ul>
<h2>What this means for budgeting</h2>
<p>My previous article's advice still holds: do not hardcode the number. Apple added the tools to follow it:</p>
<ul>
<li><code>contextSize</code> works on both <code>SystemLanguageModel</code> and <code>PrivateCloudComputeLanguageModel</code>.</li>
<li><code>tokenCount(for:)</code> counts instructions, prompts and transcripts.</li>
<li>A <code>usage</code> property on responses reports input, output and reasoning token counts.</li>
<li>For PCC limits, session 319 says to check <code>isLimitReached</code> on the model's <code>quotaUsage</code>, and to show persistent UI instead of an alert.</li>
</ul>
<h2>A pattern I would use</h2>
<p>This section is my opinion, not Apple's.</p>
<ol>
<li><strong>Measure before you choose.</strong> Count the tokens of the real prompt with <code>tokenCount(for:)</code> and compare against <code>contextSize</code>.</li>
<li><strong>Leave room for the answer.</strong> For PCC, that includes reasoning tokens.</li>
<li><strong>Route by data, not vibes.</strong> Apple's own phrase, from session 319, is to decide between models "based on data, not just vibes" with the new Evaluations framework.</li>
<li><strong>Plan for the limit.</strong> A PCC daily limit is a different failure from a full context window. Decide what the user sees in each.</li>
</ol>
<p>A sketch, not compiled:</p>
<pre><code class="language-swift">let model = SystemLanguageModel()
let window = model.contextSize
let promptTokens = try await model.tokenCount(for: prompt)
// leave room for the reply; if it does not fit,
// trim, split the task, or route to the server model
</code></pre>
<h2>What I could not verify</h2>
<p>I did not run any of this. The 8192 is a code comment in a sample, and I did not find a table of per-device windows. Apple does not give numbers for the PCC daily limit. The "4K" in session 319 is a spoken comparison, not a documented spec. Third-party posts say more about these APIs, and I did not use them. These are developer-beta APIs, and details can change.</p>
<p>Sources, accessed October 16, 2026: Apple Developer, WWDC26 session 241 "What's new in the Foundation Models framework" and session 319 "Build with the new Apple Foundation Model on Private Cloud Compute"; Technote TN3193, "Managing the on-device foundation model's context window" (developer.apple.com).</p>
<p>If Apple says to adapt to the hardware, what does your app do when contextSize returns a number you never tested?</p>
<p>#iOS #AI #OnDeviceAI</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[Apple's On-Device Model Has a 4,096-Token Window, and Your App Spends It Faster Than You Think]]></title><description><![CDATA[Apple's on-device model has a 4,096-token window per session. What spends it, a worked budget, and how to handle the limit in your iOS app.]]></description><link>https://sairamkotha.hashnode.dev/apple-on-device-model-4096-token-context-window</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/apple-on-device-model-4096-token-context-window</guid><category><![CDATA[iOS]]></category><category><![CDATA[AI]]></category><category><![CDATA[Swift]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[llm]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Thu, 15 Oct 2026 02:48:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/b4df915e-0d4c-4fb3-8d22-31bc831c95e9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Apple's on-device model is free, private and fast. It also has a context window of 4,096 tokens, and that window is shared by more things than most developers count. A chat feature that works in a demo can run out of room within a handful of turns once real instructions, tools and structured output are added.</p>
<p>This article follows Apple's own documentation: what the window is, what spends it, a worked budget, what Apple recommends, and what to do when you hit it.</p>
<p>How to read it: every claim about the Foundation Models framework comes from Apple's documentation, read on October 15, 2026, mainly technote TN3193 and the API pages for <code>SystemLanguageModel</code>. The budget example uses sizes I assumed, and the code is a sketch I have not compiled. Where Apple's pages were silent, I say so.</p>
<h2>The number</h2>
<p>TN3193 states it plainly: Apple's on-device foundation model has a context window of 4096 tokens per language model session. Apple explains the reason too: an on-device model running on an iPhone needs a relatively small window to run efficiently.</p>
<p>Two details matter:</p>
<ul>
<li><strong>It is per session, not per message.</strong> The <code>contextSize</code> documentation says it is the total number of tokens that can be used in a single session, including both input prompts and generated responses.</li>
<li><strong>Tokens are small.</strong> Apple says a token is roughly three to four characters in Latin-alphabet languages like English, and roughly one character per token in Chinese, Japanese and Korean.</li>
</ul>
<p>My arithmetic: 4,096 tokens at 3 to 4 characters is about 12,000 to 16,000 English characters. That is a few pages of text for the whole conversation, instructions and replies included. For Chinese, Japanese or Korean it is closer to 4,000 characters.</p>
<h2>What spends the window</h2>
<p>The technote lists everything that contributes tokens to the current session:</p>
<ul>
<li>Your instructions.</li>
<li>Every prompt you send.</li>
<li>Tool information: the schemas, the arguments the model passes, and the tool's output.</li>
<li>Your <code>Generable</code> schemas.</li>
<li>All of the model's responses.</li>
</ul>
<p>Two of these are easy to miss. For every <code>Generable</code> type, the framework converts its type and format information into a JSON schema and passes that text to the model, and any <code>@Guide</code> descriptions you write also cost tokens. For tools, the name, description and parameters go into the prompt, and each tool call's output comes back in as more tokens.</p>
<p>The consequence is that a session is a running transcript. Each turn adds your prompt and the model's reply, and nothing leaves until you start a new session.</p>
<h2>A worked budget</h2>
<p>These sizes are my assumptions, chosen to be plausible for a small feature, not measurements:</p>
<ul>
<li>Instructions: 300 tokens.</li>
<li>Five tools at about 150 tokens each for name, description and parameters: 750 tokens.</li>
<li>One <code>Generable</code> type with a few <code>@Guide</code> notes: 200 tokens.</li>
</ul>
<p>That is 1,250 tokens spent before the user says anything, leaving 2,846. If a turn is a 200-token prompt and a 300-token reply, that is 500 tokens per turn, so the chat has room for about five turns. If a tool returns a long result, fewer.</p>
<p>Your numbers will differ. The point is the shape: a fixed overhead you pay once, then a per-turn cost that adds up, in a window small enough that the overhead is a third of it.</p>
<h2>What Apple tells you to do</h2>
<p>TN3193 is written as a diet. Its advice, in my grouping:</p>
<ul>
<li><strong>Measure first.</strong> Use <code>tokenCount(for:)</code> on your instructions, prompts, tools, schema and transcript entries, and use the Foundation Models instrument in Xcode to watch token use while the app runs.</li>
<li><strong>Split big tasks.</strong> Run each step in a new session and assemble the results. For a long article, summarize each chunk in its own session and pass the previous summary forward.</li>
<li><strong>Ask for less.</strong> Put the target length in the prompt ("In 3 sentences...") and cap array sizes with <code>@Guide</code> and a maximum count.</li>
<li><strong>Shrink prompts.</strong> Aim for one to three paragraphs, use concise imperative language, and give the model only the information the task needs.</li>
<li><strong>Keep types small.</strong> Short property names, and add <code>@Guide</code> only where the model needs help.</li>
<li><strong>Limit tools.</strong> Keep tool and <code>@Guide</code> descriptions to a short phrase and give the model a maximum of three to five tools. If the model should always have a tool's data, run the tool yourself first and put the result in the prompt.</li>
<li><strong>Use retrieval.</strong> For large knowledge bases, fetch only the relevant snippets instead of loading everything.</li>
</ul>
<p>One warning stands out. Apple says to use <code>maximumResponseTokens</code> only to protect against runaway generation, because a strict cap can make the model produce malformed or grammatically incorrect partial output, like "A cat is a small."</p>
<h2>When you hit the limit</h2>
<p>The framework throws <code>.exceededContextWindowSize</code>, and the session cannot respond. A new session has a fresh window but not the old state. Apple's options are to summarize the old transcript into a new session, or keep only the important entries. Its example keeps the first and last:</p>
<pre><code class="language-swift">func newContextualSession(with original: LanguageModelSession) -&gt; LanguageModelSession {
    let all = original.transcript
    let condensed = [all.first, all.last].compactMap { $0 }
    var newSession = LanguageModelSession(transcript: Transcript(entries: condensed))
    newSession.prewarm()
    return newSession
}
</code></pre>
<p>That example is Apple's, lightly shortened. Keeping the first and last entry is a starting point, not a quality guarantee, so test what your feature actually loses.</p>
<h2>Do not hardcode 4,096</h2>
<p>Apple added two APIs for this. <code>SystemLanguageModel.contextSize</code> returns the maximum context size in tokens that the model supports, and <code>tokenCount(for:)</code> returns the token count for your input. Apple's <code>contextSize</code> page marks it as back-deployed before iOS 26.4, and its <code>tokenCount(for:)</code> page lists availability from iOS 26.4. InfoQ's write-up says both additions are back-deployed, so check the availability on your deployment target. InfoQ's reading is that the point is to remove the need to hardcode the limit.</p>
<p>There is a reason beyond tidiness. The <code>SystemLanguageModel</code> page says Apple periodically updates the model in routine OS updates, and currently lists three model versions: iOS, iPadOS, macOS and visionOS 26.0 to 26.3, 26.4, and 27.0. Apple's guidance is to test your prompts against old and new versions and to version them with <code>#available</code> checks. The technote documents 4,096 for the model it describes, and I did not find a documented window for the 27.0 model. Read the limit at runtime and re-measure after each OS update.</p>
<pre><code class="language-swift">let model = SystemLanguageModel.default
let limit = try model.contextSize
let instructionTokens = try await model.tokenCount(for: instructions)
</code></pre>
<h2>A pattern I would use</h2>
<p>This section is my opinion, not Apple's.</p>
<ol>
<li><strong>Budget at launch.</strong> Count the tokens of your instructions, tools and schemas once, and compute how much is left for conversation.</li>
<li><strong>Keep a reserve.</strong> Do not plan to fill the window. Leave room for a long reply.</li>
<li><strong>One task, one session.</strong> Start a new session for each distinct job instead of one forever chat.</li>
<li><strong>Decide the overflow behavior in advance.</strong> Summarize, trim, or hand the task to a server model. Do not let the error be the first time you think about it.</li>
<li><strong>Re-test on each OS release.</strong> Prompts and limits can change with the model version.</li>
</ol>
<h2>What I could not verify</h2>
<p>I did not run any of this on a device, so the budget example and the code are untested illustrations. I did not find a documented context window for the iOS 27.0 model, so I make no claim about it. Reports about newer Apple models describe input limits as undisclosed, and I did not use them. Apple's token-per-character figure is a rule of thumb, so measure your real strings. Device and region availability, and model behavior, vary and change.</p>
<p>Sources, accessed October 15, 2026: Apple Developer Documentation: TN3193, "Managing the on-device foundation model's context window"; SystemLanguageModel; contextSize; tokenCount(for:); "Updating prompts for new model versions" (developer.apple.com). InfoQ, "Apple Foundation Models context" (infoq.com, March 2026).</p>
<p>If your on-device feature kept a conversation going for ten minutes, do you know how many tokens it would have left?</p>
<p>#iOS #AI #OnDeviceAI #Swift</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item><item><title><![CDATA[Anthropic Replaced Git for Its Monorepo. The Reason Matters More Than Scion.]]></title><description><![CDATA[Anthropic moved its monorepo off git to Scion at 30,000 merges a day. What broke, what changed, and what smaller teams can take from it.]]></description><link>https://sairamkotha.hashnode.dev/anthropic-scion-source-control-replaces-git</link><guid isPermaLink="true">https://sairamkotha.hashnode.dev/anthropic-scion-source-control-replaces-git</guid><category><![CDATA[Git]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Sai Ram Kotha]]></dc:creator><pubDate>Wed, 14 Oct 2026 16:09:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac84ce557988645852d8c4c/c55c412b-e6a6-48b6-9454-04e3cfceb5eb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Anthropic just published how it moved its main repository off git and onto an in-house system called Scion. The headlines say "Anthropic replaces git." The post says something narrower and more useful: git did exactly what it was designed to do, and that design stopped matching how the code was being used.</p>
<p>This article follows the post: what broke, what Scion changes, and what a team that is not Anthropic can take from it.</p>
<p>How to read it: every claim about Scion comes from Anthropic's engineering post "Redesigning source control at Anthropic", published October 13, 2026, which I read on October 14. I have not used Scion and have no inside knowledge. The arithmetic sections are mine and are labeled. Where the post was silent, I say so.</p>
<h2>The numbers behind it</h2>
<p>The post gives these figures for Anthropic's monorepo:</p>
<ul>
<li>Merges went from around 2,500 code changes per day in May 2026 to over 30,000 per day in September.</li>
<li>The history of the main branch alone is almost 100 GB, and a compressed working copy is over 25 GB.</li>
<li>CI performs on the order of 25 million code checkouts per day, before developer environments, agent sandboxes and research jobs.</li>
<li>Fetched naively, that would be over 600 PB of git traffic per day.</li>
</ul>
<p>The post's first line says that as merges per day grew more than tenfold, source control became the biggest development bottleneck.</p>
<p>My arithmetic on those figures: 30,000 merges a day is about one every 2.9 seconds, around the clock. 25 million checkouts a day is about 290 per second. And 600 PB divided by 25 million is about 24 GB per checkout, which lines up with the 25 GB working copy. So the 600 PB figure is just "every checkout downloads the whole repo."</p>
<h2>Why git broke</h2>
<p>The post's core diagnosis is a mismatch. Git is built for distributed, offline work: every machine holds the history and can work alone. Anthropic was using it as a centralized system with one authoritative copy, whose clients are nearly always online agents and jobs. The post says the properties that make git excellent for distributed, offline work were actively undermining that use.</p>
<p>The team had piled up the standard workarounds: shallow clones, sparse checkouts, promisor remotes, internal git mirrors and blob-storage seeds. At its peak Anthropic ran several hundred internal git mirrors. Three things gave out:</p>
<ul>
<li>Seeds drifted further from the tip of main between refreshes.</li>
<li>Diff and blame on partial clones got harder to keep working as the merge rate rose.</li>
<li>Mirrors stopped scaling once the repo outgrew their disks or memory, and became harder to operate as the merge rate increased.</li>
</ul>
<p>None of this means git is slow. It means a design for a thousand offline laptops met thirty thousand merges a day from machines that are always connected.</p>
<h2>What Scion does differently</h2>
<p>The post lists six design principles:</p>
<ol>
<li><strong>Clients are always online.</strong> A client talks to a reachable server and fetches data when it needs it.</li>
<li><strong>Client-server asymmetry.</strong> The server handles history, blame, commits, merges and rebases. The client keeps a bounded local cache and lays out the working copy.</li>
<li><strong>Source control as a service.</strong> Queries are exposed through an API, so many jobs need no local repository. Automated bisect and revert pipelines depend on this.</li>
<li><strong>Horizontal scalability.</strong> No full copy of the repo exists anywhere, even on the server, and there are no packfiles or repo maintenance.</li>
<li><strong>Cheap, lock-free commits.</strong> Commits, rebases and merges happen on the server, so there is nothing to push. Refs are discouraged except for main and release branches.</li>
<li><strong>Optional, sparse working copies.</strong> CI can ask the server for diffs, and many writes skip checkout entirely.</li>
</ol>
<p>The data model is a changeset, an entry in a write-ahead log, created on the server. It lists changed files relative to its first parent, with content-addressed pointers to compressed blobs. Refs are a compare-and-swap on a single Spanner row. Blame, path history and commit-graph skip lists are derived data, computed from parent values, so the internals can change without client changes.</p>
<p>A small fleet of servers answers all the APIs. Because everything is content-addressed, any server can cache it without coordination. The client fetches only the trees and blobs it wants, starting at a root tree and recursing, so there is no multi-gigabyte packfile and transfers are resumable.</p>
<p>Delivery is tiered: a scion-daemon on each node, a regional scion-cache, and the authoritative scion-server that reads from Spanner or cloud storage on a miss. The post says tiering is strictly enforced so a failing regional cache cannot overwhelm upstream servers.</p>
<h2>What Anthropic reports</h2>
<p>All figures below are the post's own claims, not independent measurements:</p>
<ul>
<li>The Scion infrastructure footprint is 200x smaller than the previous git mirroring infrastructure, in CPU and memory.</li>
<li>CI and research jobs check out code two orders of magnitude faster, in seconds rather than minutes.</li>
<li>CI alone realized a 25% CPU capacity win.</li>
<li>Each tier keeps a cache hit rate of over 99%.</li>
<li>A team of seven people built Scion and the code review tool Antique and migrated to both in five months.</li>
<li>Claude Mythos Preview rewrote the git-to-Scion ingestion pipeline, producing a version that used 20x less memory and ran 4x faster.</li>
</ul>
<p>The post does not describe the cutover plan or downtime, so I can say nothing about how the migration was done.</p>
<h2>The part most people skip</h2>
<p>Two paragraphs in the post are about people and process, not storage.</p>
<p>Human-scale practices stopped fitting. The post says Anthropic has not been able to effectively replicate the industry standard of a global repository lock on the merge queue. It has invested in post-merge and pre-deploy automated testing instead, and says Claude can typically fix conflicts faster than humans can even identify them. Reviewers now examine the intent of a change instead of reading every line, and the code review tool can express complex semantic constraints on approvals.</p>
<p>The engineer's job moved toward saying no. The post says the role of the engineers was to tell Claude "no," and gives two examples: multiple systems that tried to brute-force looking up when a change merged to main, and one review that was overly defensive about trusting another internal service.</p>
<h2>Who should not copy this</h2>
<p>The post is direct about the limits. Scion is "by no means a suitable replacement for git for general use," and it fits an organization with either tens of thousands of engineers or swarms of agents prolifically writing code. The post says Anthropic's ability to spend tokens for internal use far exceeds what any other business would realistically want to spend. And because Scion diverges from git, adopting it means replacing code review, code search and CI too, which are highly bespoke inside Anthropic.</p>
<p>The post does not announce a release. It says it is difficult to offer Scion in a form that would be broadly useful, and that the most impactful thing Anthropic can do is share its thinking. It names three directions for the wider ecosystem: git servers that do not mirror client behavior, server-side RPCs on git platforms, and a redesigned negotiation protocol where sparse and shallow features compose well.</p>
<h2>What a smaller team can take from it</h2>
<p>This section is my opinion, not the post's.</p>
<ol>
<li><strong>Count your own checkouts.</strong> Most teams have never added up how many times per day CI, agents and sandboxes fetch the repo. At agent scale that number, not developer headcount, is what breaks things.</li>
<li><strong>Treat source control as an API for machines.</strong> If a job only needs to know what changed, asking a server beats cloning.</li>
<li><strong>Look for human-scale locks.</strong> Merge queues, serialized deploys and manual review gates all assume a human pace. Anthropic's own post says it could not make a global merge-queue lock work at its volume.</li>
<li><strong>Do not rebuild git.</strong> Anthropic needed this at 30,000 merges a day with a seven-person team and its own models doing the work. Try the sparse and shallow tools git already has first.</li>
</ol>
<h2>What I could not verify</h2>
<p>I read one primary source and did not test Scion or run any of the numbers. Every performance figure is Anthropic's claim. The post does not describe the cutover plan, downtime, or whether the 600 PB figure was ever actually transferred rather than being a calculation. Several news write-ups say Anthropic will not open-source Scion or give a different reading of the post. I used only the post's own wording. Details may change.</p>
<p>Source, accessed October 14, 2026: Anthropic Engineering, "Redesigning source control at Anthropic" (anthropic.com/engineering/redesigning-source-control).</p>
<p>If your agents started merging ten times more code next quarter, which part of your pipeline would break first?</p>
<p>#AI #Git #SoftwareEngineering #AIAgents</p>
<hr />
<p><strong>Work with me:</strong> I take 1:1 calls on mobile architecture reviews, mobile and AI questions, and startup tech consults. Book a slot on Topmate: <a href="https://topmate.io/sairamkotha">topmate.io/sairamkotha</a></p>
]]></content:encoded></item></channel></rss>