<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: guoqiang deng</title>
    <description>The latest articles on DEV Community by guoqiang deng (@guoqiang_deng_fe1db2c77a6).</description>
    <link>https://dev.to/guoqiang_deng_fe1db2c77a6</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4157977%2F7c6c02ff-cafb-47b9-bb18-a1df9a2ebc5a.JPG</url>
      <title>DEV Community: guoqiang deng</title>
      <link>https://dev.to/guoqiang_deng_fe1db2c77a6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/guoqiang_deng_fe1db2c77a6"/>
    <language>en</language>
    <item>
      <title>Building an AI Keyboard for iOS: What They Don't Tell You About Keyboard Extensions</title>
      <dc:creator>guoqiang deng</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:37:30 +0000</pubDate>
      <link>https://dev.to/guoqiang_deng_fe1db2c77a6/building-an-ai-keyboard-for-ios-what-they-dont-tell-you-about-keyboard-extensions-39ij</link>
      <guid>https://dev.to/guoqiang_deng_fe1db2c77a6/building-an-ai-keyboard-for-ios-what-they-dont-tell-you-about-keyboard-extensions-39ij</guid>
      <description>&lt;h1&gt;
  
  
  Building an AI Keyboard for iOS: What They Don't Tell You About Keyboard Extensions
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;TL;DR: I built &lt;strong&gt;SmartReply&lt;/strong&gt; — an iOS system keyboard that reads messages (from clipboard or screenshot OCR), sends them to an AI backend (DeepSeek), and drops 5 reply candidates right into your chat app. The hardest parts aren't the AI calls. They're the iOS sandbox, the process isolation, and StoreKit 2 inside an extension. Here's what I learned.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Another AI Keyboard?
&lt;/h2&gt;

&lt;p&gt;AI keyboards aren't new. SwiftKey had prediction for a decade. Gboard has AI reply in some markets. But every one I tried had the same gap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You paste a message → you switch to the keyboard → you tap "AI" → you copy the result back to the chat.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's four taps and two context switches. My pitch was one tap. From a screenshot. So if someone sends you "Hey, are we still on for tonight?" while you're in WhatsApp, you don't even need to copy it. You double-tap the back of your iPhone, the keyboard fires up, and five reply candidates are already sitting there.&lt;/p&gt;

&lt;p&gt;Here's what the architecture actually looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Keyboard Extension Actually Is
&lt;/h2&gt;

&lt;p&gt;Let's get one thing straight before I show code: &lt;strong&gt;iOS keyboard extensions are second-class citizens&lt;/strong&gt;. Apple designed them that way for security, and every limitation is a product constraint you have to design around.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Separate process&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your keyboard runs in its own sandbox. It has no memory, no file access, no API access to the host app. Not even UserDefaults works between the main app and the extension.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No network by default&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;URLSession&lt;/code&gt; calls silently fail unless the user has toggled "Allow Full Access" in Settings → Keyboard → SmartReply. And even with Full Access, some APIs still behave differently.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No StoreKit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;StoreKit 2&lt;/code&gt;'s &lt;code&gt;Transaction.currentEntitlements&lt;/code&gt; is not available in extensions. You cannot verify a subscription from the keyboard. Period.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Limited lifecycle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The OS creates and destroys the extension's process on demand. One second it's there, the next it's been garbage-collected. You can't schedule work, you can't cache aggressively, you can't rely on any long-lived state.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Can't launch anything&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The extension cannot present &lt;code&gt;SFSafariViewController&lt;/code&gt;, cannot open deep links reliably, and cannot present modals outside its own view hierarchy.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every design decision in SmartReply comes from this table.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: Three Processes, One Goal
&lt;/h2&gt;

&lt;p&gt;Three moving pieces share the work. The main app owns user-facing flow, subscription, and heavy computation (OCR, Shortcut intent). The keyboard owns UI and insertion. The backend (Cloudflare Workers, 908 lines) owns AI calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Communication&lt;/strong&gt;: Main app ⇄ Extension via App Group (&lt;code&gt;group.com.smartkey.ime&lt;/code&gt;) + Darwin notifications (one-way "ping, check the store" — no data passed, just a signal).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend endpoints&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;POST /ai/reply&lt;/code&gt; — takes message text, calls DeepSeek API, returns 5 candidates&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;POST /ocr&lt;/code&gt; — takes base64 image, runs OCR server-side (fallback for users without on-device OCR)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;POST /verify&lt;/code&gt; — subscription validation (receipt → App Store → cached entitlement)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;GET /config&lt;/code&gt; — remote flags (daily free limit, feature toggles, announcements)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;POST /subscribe&lt;/code&gt; — landing page email collection → Workers KV&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Darwin Notification Helper (~30 lines)
&lt;/h3&gt;

&lt;p&gt;A single direction: main app knocks on the keyboard's door. The keyboard then reads the App Group store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;CoreFoundation&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;SKDarwin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;/// Main app fired Back Tap Shortcut → OCR'd the screenshot → AI'd → results ready in App Group&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pendingScanReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.smartkey.darwin.pendingScan"&lt;/span&gt;

    &lt;span class="c1"&gt;/// Main app pulled fresh remote config → keyboard should re-read (daily limit changed)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;configUpdated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.smartkey.darwin.config"&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFNotificationCenterGetDarwinNotifyCenter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kt"&gt;CFNotificationCenterPostNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="kt"&gt;CFNotificationName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  App Group Store
&lt;/h3&gt;

&lt;p&gt;Thin wrapper around &lt;code&gt;UserDefaults(suiteName:)&lt;/code&gt; — the only IPC Apple allows between an app and its extension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;AppGroupStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;defaults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;suiteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"group.com.smartkey.ime"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pendingScanKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"pending.scan.v1"&lt;/span&gt;

    &lt;span class="c1"&gt;// Main app writes after Back Tap Shortcut → OCR → AI&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;savePendingScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SKPendingScan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try!&lt;/span&gt; &lt;span class="kt"&gt;JSONEncoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pendingScanKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kt"&gt;SKDarwin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SKDarwin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingScanReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ping&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Keyboard reads on next viewDidAppear&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;loadPendingScan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;SKPendingScan&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pendingScanKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SKPendingScan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;consumePendingScan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pendingScanKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Three Pivots That Almost Killed the Project
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pivot 1: StoreKit 2 Is Not Available in Extensions (and That's Fine)
&lt;/h3&gt;

&lt;p&gt;I wasted two days trying to import &lt;code&gt;StoreKit&lt;/code&gt; into &lt;code&gt;KeyboardViewController&lt;/code&gt;. It compiles. It even runs. But &lt;code&gt;Transaction.currentEntitlements&lt;/code&gt; returns nothing — ever. Apple silently blocks it in extensions.&lt;/p&gt;

&lt;p&gt;The fix is stupidly simple: &lt;strong&gt;let the main app handle subscriptions, pass the result through the App Group&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Main app calls &lt;code&gt;SKSubscriptionManager.shared.start()&lt;/code&gt; on launch → fetches entitlements → writes a &lt;code&gt;SubscriptionEntitlement&lt;/code&gt; struct to the shared store&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keyboard extension reads it on every &lt;code&gt;viewDidAppear&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// KeyboardViewController.swift&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;refreshEntitlement&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;entitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AppGroupStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadEntitlements&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;featureLock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dailyLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;featureLock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entitle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;unlimited&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dailyLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entitle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;freeRemaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No direct StoreKit calls in the extension. Anywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pivot 2: Keyboard + Back Tap + Shortcuts = A Lifecycle Minefield
&lt;/h3&gt;

&lt;p&gt;User flow I wanted:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User is in WhatsApp, sees a message&lt;/li&gt;
&lt;li&gt;User double-taps the back of their iPhone (Back Tap → Shortcuts)&lt;/li&gt;
&lt;li&gt;Shortcut takes a screenshot → runs an App Intent → main app does OCR + AI&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keyboard extension shows AI candidates on top of the chat app&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 was the problem. The system creates keyboard extensions &lt;strong&gt;only when a text field is focused&lt;/strong&gt;. If the user double-taps while browsing WhatsApp (not typing), the extension isn't even in memory. No process. No one to talk to.&lt;/p&gt;

&lt;p&gt;Three workarounds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Feasibility&lt;/th&gt;
&lt;th&gt;UX&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overlay a window on the host app&lt;/td&gt;
&lt;td&gt;❌ iOS doesn't allow it&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep-link to main app, show candidates there&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Terrible — covers the chat app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Store results in App Group, show them when the user next opens the keyboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Good enough, and zero dismiss code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I went with option 3. If the keyboard isn't active, the AI result just waits. When the user next taps a text field → &lt;code&gt;viewDidAppear&lt;/code&gt; fires → &lt;code&gt;loadPendingScanIfAny()&lt;/code&gt; reads it → candidates appear at the top. 95% of the time the user IS typing when they double-tap, so the extension is already alive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pivot 3: "Allow Full Access" Is a User Trust Problem, Not a Code Problem
&lt;/h3&gt;

&lt;p&gt;Apple's keyboard permission prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"SmartReply would like to Allow Full Access"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most users don't know what it means. Many distrust it. And without it, &lt;code&gt;URLSession&lt;/code&gt; from the keyboard extension silently fails — no network, no AI replies, nothing works.&lt;/p&gt;

&lt;p&gt;Solved with three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In-app onboarding&lt;/strong&gt;: Explicitly shows &lt;em&gt;why&lt;/em&gt; Full Access is needed ("Full Access lets the keyboard talk to our AI service — we do not log keystrokes or track what you type")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Store Connect review notes&lt;/strong&gt;: Same explanation, quoted verbatim for the reviewer to copy-paste&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy policy&lt;/strong&gt;: Dedicated section titled &lt;em&gt;iOS "Allow Full Access"&lt;/em&gt; — explains the boundary, explicitly states what the permission is NOT used for&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Apple reviewed SmartReply without asking a single follow-up about Full Access. Your reviewer is a person reading these notes. Write for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Looks Like When It Works
&lt;/h2&gt;

&lt;p&gt;A 15-second product video, 1080×1920 / 30fps / 1.3 MB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Frame&lt;/th&gt;
&lt;th&gt;Seconds&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;0–1.5&lt;/td&gt;
&lt;td&gt;Clean WhatsApp chat + incoming message, dark blur vignette, caption "When you have no idea how to reply"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Back Tap&lt;/td&gt;
&lt;td&gt;1.5–3&lt;/td&gt;
&lt;td&gt;System keyboard background + two white ripple circles expanding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCR → AI&lt;/td&gt;
&lt;td&gt;3–7&lt;/td&gt;
&lt;td&gt;Screenshot OCR (Vision), DeepSeek API call, keyboard panel slides up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 Candidates&lt;/td&gt;
&lt;td&gt;7–11&lt;/td&gt;
&lt;td&gt;5 replies appear with tone labels (Auto / Formal / Casual / Warm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert → Send&lt;/td&gt;
&lt;td&gt;11–13&lt;/td&gt;
&lt;td&gt;Text autofills input field, user taps send, blue bubble lands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand&lt;/td&gt;
&lt;td&gt;13–15&lt;/td&gt;
&lt;td&gt;White fade-in, app icon + SmartReply&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;🎬 Full video on YouTube: &lt;a href="https://youtube.com/shorts/zG8DMzwSTV8" rel="noopener noreferrer"&gt;https://youtube.com/shorts/zG8DMzwSTV8&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Launch Notes for Other Solo Devs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard extensions are 10% of your codebase but 50% of your debugging time.&lt;/strong&gt; Start designing with their limitations, not their capabilities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Group + Darwin + Main App orchestration&lt;/strong&gt; is the standard pattern. I've seen three AI keyboards and they all use it. Don't try to be clever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test subscription flow on device, not simulator.&lt;/strong&gt; StoreKit sandbox behaves differently from production, and extension-specific bugs (like &lt;code&gt;URLSession&lt;/code&gt; without Full Access) only show up on real hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple's review team will read your review notes.&lt;/strong&gt; Don't write "see privacy policy". Write the full explanation there, once, clearly.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  If You Want to Try It
&lt;/h3&gt;

&lt;p&gt;SmartReply is an iOS app (iPhone + iPad, iOS 16+) that runs as a system keyboard. It's currently in App Store review. You can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Join TestFlight&lt;/strong&gt; — reply here with your email, I'll add you&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the landing page&lt;/strong&gt; → &lt;a href="https://smartkey-api.pages.dev" rel="noopener noreferrer"&gt;https://smartkey-api.pages.dev&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Watch the demo&lt;/strong&gt; → &lt;a href="https://youtube.com/shorts/zG8DMzwSTV8" rel="noopener noreferrer"&gt;https://youtube.com/shorts/zG8DMzwSTV8&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Subscribe on launch&lt;/strong&gt; — drop your email on the landing page, I'll send a free yearly promo code when it goes live&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Follow the launch&lt;/strong&gt; — posting to Product Hunt on October 8th, 2026 (Pacific 00:01)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Thanks for reading. If you're building something similar, hit me up — the keyboard extension ecosystem is small, the problems are real, and I'd trade war stories.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>keyboards</category>
      <category>ai</category>
      <category>solodev</category>
    </item>
  </channel>
</rss>
