Building an AI Keyboard for iOS: What They Don't Tell You About Keyboard Extensions
TL;DR: I built SmartReply — 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.
Why Another AI Keyboard?
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:
You paste a message → you switch to the keyboard → you tap "AI" → you copy the result back to the chat.
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.
Here's what the architecture actually looks like.
What a Keyboard Extension Actually Is
Let's get one thing straight before I show code: iOS keyboard extensions are second-class citizens. Apple designed them that way for security, and every limitation is a product constraint you have to design around.
| Constraint | What it means |
|---|---|
| Separate process | 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. |
| No network by default |
URLSession 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. |
| No StoreKit |
StoreKit 2's Transaction.currentEntitlements is not available in extensions. You cannot verify a subscription from the keyboard. Period. |
| Limited lifecycle | 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. |
| Can't launch anything | The extension cannot present SFSafariViewController, cannot open deep links reliably, and cannot present modals outside its own view hierarchy. |
Every design decision in SmartReply comes from this table.
Architecture: Three Processes, One Goal
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.
Communication: Main app ⇄ Extension via App Group (group.com.smartkey.ime) + Darwin notifications (one-way "ping, check the store" — no data passed, just a signal).
Backend endpoints:
POST /ai/reply— takes message text, calls DeepSeek API, returns 5 candidatesPOST /ocr— takes base64 image, runs OCR server-side (fallback for users without on-device OCR)POST /verify— subscription validation (receipt → App Store → cached entitlement)GET /config— remote flags (daily free limit, feature toggles, announcements)POST /subscribe— landing page email collection → Workers KV
Darwin Notification Helper (~30 lines)
A single direction: main app knocks on the keyboard's door. The keyboard then reads the App Group store:
import Foundation
import CoreFoundation
public enum SKDarwin {
/// Main app fired Back Tap Shortcut → OCR'd the screenshot → AI'd → results ready in App Group
public static let pendingScanReady = "com.smartkey.darwin.pendingScan"
/// Main app pulled fresh remote config → keyboard should re-read (daily limit changed)
public static let configUpdated = "com.smartkey.darwin.config"
public static func post(_ name: String) {
let center = CFNotificationCenterGetDarwinNotifyCenter()
CFNotificationCenterPostNotification(
center,
CFNotificationName(name as CFString),
nil, nil, true
)
}
}
App Group Store
Thin wrapper around UserDefaults(suiteName:) — the only IPC Apple allows between an app and its extension:
public final class AppGroupStore {
private let defaults = UserDefaults(suiteName: "group.com.smartkey.ime")!
private let pendingScanKey = "pending.scan.v1"
// Main app writes after Back Tap Shortcut → OCR → AI
public func savePendingScan(_ scan: SKPendingScan) {
let data = try! JSONEncoder().encode(scan)
defaults.set(data, forKey: pendingScanKey)
SKDarwin.post(SKDarwin.pendingScanReady) // ping
}
// Keyboard reads on next viewDidAppear
public func loadPendingScan() -> SKPendingScan? {
guard let data = defaults.data(forKey: pendingScanKey) else { return nil }
return try? JSONDecoder().decode(SKPendingScan.self, from: data)
}
public func consumePendingScan() {
defaults.removeObject(forKey: pendingScanKey)
}
}
The Three Pivots That Almost Killed the Project
Pivot 1: StoreKit 2 Is Not Available in Extensions (and That's Fine)
I wasted two days trying to import StoreKit into KeyboardViewController. It compiles. It even runs. But Transaction.currentEntitlements returns nothing — ever. Apple silently blocks it in extensions.
The fix is stupidly simple: let the main app handle subscriptions, pass the result through the App Group.
Main app calls
SKSubscriptionManager.shared.start()on launch → fetches entitlements → writes aSubscriptionEntitlementstruct to the shared storeKeyboard extension reads it on every
viewDidAppear:
// KeyboardViewController.swift
func refreshEntitlement() {
guard let entitle = AppGroupStore.shared.loadEntitlements() else {
featureLock = .free(dailyLimit: 5)
return
}
featureLock = entitle.isActive ? .unlimited : .free(dailyLimit: entitle.freeRemaining)
}
No direct StoreKit calls in the extension. Anywhere.
Pivot 2: Keyboard + Back Tap + Shortcuts = A Lifecycle Minefield
User flow I wanted:
- User is in WhatsApp, sees a message
- User double-taps the back of their iPhone (Back Tap → Shortcuts)
- Shortcut takes a screenshot → runs an App Intent → main app does OCR + AI
- Keyboard extension shows AI candidates on top of the chat app
Step 4 was the problem. The system creates keyboard extensions only when a text field is focused. If the user double-taps while browsing WhatsApp (not typing), the extension isn't even in memory. No process. No one to talk to.
Three workarounds:
| Approach | Feasibility | UX |
|---|---|---|
| Overlay a window on the host app | ❌ iOS doesn't allow it | — |
| Deep-link to main app, show candidates there | ✅ | Terrible — covers the chat app |
| Store results in App Group, show them when the user next opens the keyboard | ✅ | Good enough, and zero dismiss code |
I went with option 3. If the keyboard isn't active, the AI result just waits. When the user next taps a text field → viewDidAppear fires → loadPendingScanIfAny() 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.
Pivot 3: "Allow Full Access" Is a User Trust Problem, Not a Code Problem
Apple's keyboard permission prompt:
"SmartReply would like to Allow Full Access"
Most users don't know what it means. Many distrust it. And without it, URLSession from the keyboard extension silently fails — no network, no AI replies, nothing works.
Solved with three layers:
- In-app onboarding: Explicitly shows why Full Access is needed ("Full Access lets the keyboard talk to our AI service — we do not log keystrokes or track what you type")
- App Store Connect review notes: Same explanation, quoted verbatim for the reviewer to copy-paste
- Privacy policy: Dedicated section titled iOS "Allow Full Access" — explains the boundary, explicitly states what the permission is NOT used for
Apple reviewed SmartReply without asking a single follow-up about Full Access. Your reviewer is a person reading these notes. Write for them.
What It Looks Like When It Works
A 15-second product video, 1080×1920 / 30fps / 1.3 MB:
| Frame | Seconds | Action |
|---|---|---|
| Hook | 0–1.5 | Clean WhatsApp chat + incoming message, dark blur vignette, caption "When you have no idea how to reply" |
| Back Tap | 1.5–3 | System keyboard background + two white ripple circles expanding |
| OCR → AI | 3–7 | Screenshot OCR (Vision), DeepSeek API call, keyboard panel slides up |
| 5 Candidates | 7–11 | 5 replies appear with tone labels (Auto / Formal / Casual / Warm) |
| Insert → Send | 11–13 | Text autofills input field, user taps send, blue bubble lands |
| Brand | 13–15 | White fade-in, app icon + SmartReply |
🎬 Full video on YouTube: https://youtube.com/shorts/zG8DMzwSTV8
Launch Notes for Other Solo Devs
- Keyboard extensions are 10% of your codebase but 50% of your debugging time. Start designing with their limitations, not their capabilities.
- App Group + Darwin + Main App orchestration is the standard pattern. I've seen three AI keyboards and they all use it. Don't try to be clever.
-
Test subscription flow on device, not simulator. StoreKit sandbox behaves differently from production, and extension-specific bugs (like
URLSessionwithout Full Access) only show up on real hardware. - Apple's review team will read your review notes. Don't write "see privacy policy". Write the full explanation there, once, clearly.
If You Want to Try It
SmartReply is an iOS app (iPhone + iPad, iOS 16+) that runs as a system keyboard. It's currently in App Store review. You can:
Join TestFlight — reply here with your email, I'll add you
Read the landing page → https://smartkey-api.pages.dev
Watch the demo → https://youtube.com/shorts/zG8DMzwSTV8
Subscribe on launch — drop your email on the landing page, I'll send a free yearly promo code when it goes live
Follow the launch — posting to Product Hunt on October 8th, 2026 (Pacific 00:01)
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.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.