●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Building a Custom AI Keyboard Extension with Rork Max: A Complete iOS Keyboard Extension Guide
A complete guide to building a custom iOS Keyboard Extension with Rork Max. Covers Claude API integration for AI text completion, multilingual translation, smart templates, App Store review strategy, and subscription monetization.
The keyboard is where users spend more time than almost any other interface on their iPhone. Yet the App Store is surprisingly thin on well-executed AI keyboards — especially ones that offer real intelligence, not just emoji shortcuts or custom themes. Rork Max changes this. Because it generates true SwiftUI and UIKit code, it can build Keyboard Extensions — something no other no-code tool supports.
This guide walks through the complete process: generating the Extension target in Rork Max, wiring up Claude API for real-time text completion, sharing data securely between the main app and extension via App Groups, handling the tricky Full Access requirement, and getting through App Store review. You'll get three complete, working code examples, a 10-point pitfall list, and a monetization model built around subscription revenue.
Why AI Keyboards Are One of the Best Opportunities for Indie Developers Right Now
The keyboard app category has fundamentally shifted. GBoard and SwiftKey dominated the early years, but those apps are built for everyone — which means they're optimized for no one in particular. Niche AI keyboards have been quietly building strong subscription businesses: a medical terminology completer for nurses, a code snippet inserter for developers, an SNS post generator for content creators.
What makes keyboards a particularly attractive indie bet: switching costs are enormous. Once a user builds muscle memory with your keyboard and their customizations live in your app, they don't leave. Churn rates for keyboard apps run 2-3% monthly — significantly lower than the average utility app.
Rork Max opens this category to developers who couldn't previously tackle it. The three things that make it viable: it generates UIInputViewController subclasses correctly, it handles the Claude API integration code, and it builds the App Groups configuration for data sharing between the main app and the extension.
For a size reference, capturing just 2,000 paying subscribers at $4.99/month puts you at $10,000 monthly revenue before Apple's 30% cut. That's the math that makes this worth the engineering complexity.
iOS Constraints You Must Understand Before Building
Keyboard Extensions are one of the most restricted extension types in iOS. Get these wrong and you'll ship something that doesn't work — or gets rejected.
Network access requires Full Access: The extension cannot make network requests unless the user has explicitly granted Full Access in Settings → General → Keyboards → [Your Keyboard] → Allow Full Access. This is not optional — it's an OS-level restriction. Your entire AI feature set depends on users granting this.
60MB memory limit: The extension process runs in a sandboxed container with roughly 60MB of available memory. On-device LLMs are out. Cloud APIs are the correct architecture.
App Groups for data sharing: The standard UserDefaults container isn't shared between the main app and the extension. You must use an App Groups container with UserDefaults(suiteName: "group.yourcompany.yourkeyboard"). This applies to API keys, user preferences, subscription state — everything.
No background execution: The extension only runs when the keyboard is visible. AI requests must complete within the user's attention window — typically 5 seconds maximum before they give up.
Pasteboard restrictions: Without Full Access, the extension can't read from the clipboard. Write operations (copying generated text to clipboard) are allowed, but reading isn't.
These constraints inform every architectural decision. Design for Full Access as the "premium" state that unlocks AI features, and ensure the keyboard works as a basic input device without it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Developers stuck on iOS Keyboard Extension implementation can now build a Claude API-powered AI autocomplete keyboard today
✦Master UIInputViewController architecture, App Groups data sharing patterns, and Full Access handling with working code examples
✦Get the App Store approval checklist that avoids common rejections, plus a subscription monetization blueprint for building toward $7,000/month revenue
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start with this Rork Max prompt to scaffold the project:
Create an iOS custom keyboard extension app with:
- Two targets: main Containing App and a Keyboard Extension
- App Groups shared UserDefaults for settings and subscription state
- UIInputViewController subclass with SwiftUI hosted keyboard view
- Full Access detection with graceful fallback for non-AI features
- Network permission check before any API calls
Bundle ID prefix: com.yourcompany.aikeyboard
App Group ID: group.com.yourcompany.aikeyboard
Review the generated file structure before adding any AI logic:
The hasFullAccess check before every API call is non-negotiable. Apple's review team specifically tests that extensions don't attempt network calls without it.
Implementing the Keyboard UI with SwiftUI
The keyboard UI has three layers: an AI suggestion bar at the top, the key grid in the middle, and a toolbar at the bottom. The suggestion bar is your most important UX surface — it's where the AI value becomes visible.
import SwiftUIstruct KeyboardView: View { let hasFullAccess: Bool let onTextInsert: (String) -> Void let onTextDelete: () -> Void let onNextKeyboard: () -> Void let onAIRequest: (String, @escaping (String) -> Void) -> Void @State private var suggestions: [String] = [] @State private var currentInput: String = "" @State private var isLoadingAI: Bool = false var body: some View { VStack(spacing: 0) { // AI Suggestion Bar SuggestionBar( suggestions: suggestions, isLoading: isLoadingAI, hasFullAccess: hasFullAccess, onSelect: { suggestion in onTextInsert(suggestion) clearSuggestions() } ) // Key Grid KeyGrid(onKeyTap: handleKeyTap) // Toolbar KeyboardToolbar( hasFullAccess: hasFullAccess, onNextKeyboard: onNextKeyboard, onDelete: onTextDelete, onReturn: { onTextInsert("\n") } ) } .background(Color(UIColor.systemGroupedBackground)) } private func handleKeyTap(_ key: KeyType) { switch key { case .character(let char): onTextInsert(char) currentInput += char if currentInput.count >= 3 && hasFullAccess { debouncedFetchSuggestions() } case .delete: onTextDelete() if \!currentInput.isEmpty { currentInput.removeLast() } if currentInput.count < 3 { suggestions = [] } case .space: onTextInsert(" ") clearSuggestions() case .return: onTextInsert("\n") clearSuggestions() } } private var debounceTask: Task<Void, Never>? private func debouncedFetchSuggestions() { debounceTask?.cancel() debounceTask = Task { try? await Task.sleep(nanoseconds: 400_000_000) // 400ms debounce guard \!Task.isCancelled else { return } await fetchSuggestions() } } @MainActor private func fetchSuggestions() async { isLoadingAI = true onAIRequest(currentInput) { result in let items = result .components(separatedBy: "\n") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { \!$0.isEmpty } .prefix(3) suggestions = Array(items) isLoadingAI = false } } private func clearSuggestions() { currentInput = "" suggestions = [] isLoadingAI = false }}
The 400ms debounce is critical for cost control. Without it, a user typing "Hello world" generates 11 separate API calls instead of 1-2. At Claude API pricing, this adds up fast.
For the loading state in SuggestionBar, use a shimmer animation rather than a spinner. Spinners suggest "waiting" — shimmer suggests "thinking," which feels more appropriate for AI and keeps users engaged longer.
Integrating Claude API for Real-Time AI Completion
The AIService handles all Claude communication. Key design decisions: use claude-haiku-4-5-20251001 for speed, keep max_tokens at 100 (you only need short completions), and set an 8-second timeout (generous for API calls but short enough that the extension doesn't feel frozen).
import Foundationactor AIService { static func complete(prompt: String, apiKey: String) async throws -> String { guard let url = URL(string: "https://api.anthropic.com/v1/messages") else { throw AIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 8 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") let systemPrompt = """ You are an AI keyboard autocomplete assistant. Given the text the user is currently typing, suggest 3 natural completions. Rules: - Output exactly 3 suggestions, one per line - Each suggestion should be under 25 words - Do not repeat the input text — provide only the completion - Match the tone and language of the input """ let body: [String: Any] = [ "model": "claude-haiku-4-5-20251001", "max_tokens": 150, "system": systemPrompt, "messages": [ ["role": "user", "content": "Complete: \(prompt)"] ] ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AIError.invalidResponse } guard (200...299).contains(httpResponse.statusCode) else { throw AIError.httpError(statusCode: httpResponse.statusCode) } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let content = json["content"] as? [[String: Any]], let firstContent = content.first, let text = firstContent["text"] as? String else { throw AIError.parsingFailed } return text } enum AIError: Error, LocalizedError { case invalidURL case invalidResponse case httpError(statusCode: Int) case parsingFailed var errorDescription: String? { switch self { case .invalidURL: return "Invalid API URL" case .invalidResponse: return "Invalid response from API" case .httpError(let code): return "HTTP error: \(code)" case .parsingFailed: return "Failed to parse API response" } } }}
Using actor instead of a plain struct makes AIService thread-safe, which matters because the keyboard can receive key events from multiple touch points quickly. The actor keyword ensures completion requests are processed serially, preventing race conditions in the suggestion array.
Sharing Data Securely with App Groups
The bridge between your main app (where users configure settings and manage subscriptions) and the Extension (where the keyboard lives) is App Groups UserDefaults:
// SharedDefaults.swift — used by BOTH targetsstruct SharedDefaults { static let groupID = "group.com.yourcompany.aikeyboard" private static var defaults: UserDefaults { UserDefaults(suiteName: groupID) ?? .standard } // API Configuration // In production: store in Keychain with shared access group, not UserDefaults static var apiKey: String { get { defaults.string(forKey: "api_key") ?? "" } set { defaults.set(newValue, forKey: "api_key") } } // User Preferences static var aiEnabled: Bool { get { defaults.bool(forKey: "ai_enabled") } set { defaults.set(newValue, forKey: "ai_enabled") } } static var suggestionLanguage: String { get { defaults.string(forKey: "suggestion_language") ?? "en" } set { defaults.set(newValue, forKey: "suggestion_language") } } // Subscription State — synced from RevenueCat in main app static var isPremiumActive: Bool { get { defaults.bool(forKey: "is_premium_active") } set { defaults.set(newValue, forKey: "is_premium_active") } } // Usage Tracking for Freemium Limits static var dailyCompletionCount: Int { get { defaults.integer(forKey: "daily_completion_count") } set { defaults.set(newValue, forKey: "daily_completion_count") } } static var lastResetDate: Date { get { defaults.object(forKey: "last_reset_date") as? Date ?? Date() } set { defaults.set(newValue, forKey: "last_reset_date") } } // Reset daily counter if it's a new day static func resetDailyCountIfNeeded() { let calendar = Calendar.current if \!calendar.isDateInToday(lastResetDate) { dailyCompletionCount = 0 lastResetDate = Date() } }}
Security note: Storing API keys in UserDefaults is acceptable for a BYOK (Bring Your Own Key) model where users provide their own Claude API keys. If you're hosting your own API proxy, don't put the proxy's auth token in UserDefaults — put it in the Keychain with a shared access group (kSecAttrAccessGroup). Rork Max can generate Keychain helper code with the right prompt.
Ten Pitfalls That Will Cost You a Week Each
These are the issues that cause Keyboard Extension projects to stall. Every one of them has cost developers multiple days of debugging.
1. Extension target doesn't appear in Settings → Keyboards: The NSExtensionPrincipalClass in the extension's Info.plist must use the full module-qualified class name: AIKeyboardExtension.KeyboardViewController. Without the module prefix, the system can't find the class.
2. hasFullAccess returns false on device: This property doesn't work reliably in the simulator. Always test on a physical device, with the keyboard manually added in Settings → General → Keyboards.
3. Network requests fail silently: Extensions use a restricted URLSession environment. Use URLSession(configuration: .ephemeral) and set explicit timeouts. The default timeout of 60 seconds is too long for a keyboard context — 8-10 seconds is the practical maximum.
4. App Groups UserDefaults returns nil: Both the main app target and the extension target must have the App Groups capability enabled in Xcode → Signing & Capabilities, using the exact same group identifier. A mismatch of even one character (group.com.company.app vs group.com.company.app.keyboard) will cause silent failures.
5. Keyboard height is wrong or collapses: Set height constraints in viewWillLayoutSubviews(), not viewDidLoad(). The standard heights are 216pt (portrait) and 162pt (landscape). Use UIScreen.main.bounds.height > 667 to detect larger phones if you need adaptive heights.
6. textDocumentProxy.insertText does nothing in some apps: Some apps don't implement the text input protocol properly. Detect this by checking textDocumentProxy.hasText before complex operations, and add a fallback that copies to UIPasteboard.general with a notification to the user.
7. Extension crashes with memory pressure: Don't include large image assets in the extension target. Use SF Symbols exclusively for icons, keep custom assets to small PNGs under 50KB, and avoid loading web content inside the keyboard.
8. Subscription state doesn't sync immediately: After a user purchases in the main app, the extension's SharedDefaults.isPremiumActive may not update until the next keyboard invocation. Add a NotificationCenter post from the main app that updates the App Groups value immediately after purchase confirmation.
9. App Store rejection for Guideline 2.5.4: This guideline requires that extensions not access non-shared UserDefaults. If your extension reads from UserDefaults.standard (the non-grouped container) for any reason, you'll get rejected. Audit every UserDefaults call in the extension.
10. The keyboard disappears after update: Users who update the app sometimes find the keyboard has been removed from their active keyboards list. This is an iOS behavior — add a check in the main app that detects whether the extension is active and prompts users to re-enable it if not.
Subscription Implementation: Pairing RevenueCat with Full Access
The monetization model that works for AI keyboards: free tier with daily limits, premium subscription for unlimited AI access.
In the main app (Containing App), integrate RevenueCat to manage subscriptions:
import RevenueCat// In App.init() or AppDelegate.application(_:didFinishLaunchingWithOptions:)func configureRevenueCat() { Purchases.configure(withAPIKey: "YOUR_REVENUECAT_PUBLIC_KEY") Purchases.shared.logLevel = .warn syncSubscriptionToSharedDefaults()}func syncSubscriptionToSharedDefaults() { Purchases.shared.getCustomerInfo { customerInfo, error in if let error = error { print("RevenueCat error: \(error.localizedDescription)") return } let isPremium = customerInfo?.entitlements["premium"]?.isActive == true SharedDefaults.isPremiumActive = isPremium }}// Call this after successful purchasefunc handleSuccessfulPurchase() { syncSubscriptionToSharedDefaults() // Also post a notification for any active extension instances}
In the Extension, keep it simple — no RevenueCat SDK, just read from shared state:
// In KeyboardView.swiftprivate func canUseAI() -> Bool { guard hasFullAccess else { return false } // Premium users: unlimited if SharedDefaults.isPremiumActive { return true } // Free users: 5 completions per day SharedDefaults.resetDailyCountIfNeeded() return SharedDefaults.dailyCompletionCount < 5}private func useOneCompletion() { if \!SharedDefaults.isPremiumActive { SharedDefaults.dailyCompletionCount += 1 }}
Show the paywall when canUseAI() returns false due to daily limit. The paywall should explain the value proposition in the context of what just happened: "You've used your 5 free AI completions today. Upgrade to Premium for unlimited completions." This moment-of-need framing converts significantly better than showing the paywall on first launch.
App Store Review Checklist
Keyboard Extensions get extra scrutiny. Submit only when you can check every item below.
Privacy (most critical category):
Privacy Policy URL set in both App Store Connect and the in-app settings
No logging or storage of any keystrokes beyond what's needed for the current session
Full Access necessity explained in English in the App description
App Privacy Label includes "Keyboard Input" with correct data use disclosure
GDPR/CCPA considerations addressed if targeting EU/California users
Functional requirements:
Keyboard works as a basic input device without Full Access (no features requiring network are available, but keys function)
Globe/Next Keyboard button is always visible and functional
Keyboard renders correctly in dark mode and light mode
Text is readable at large accessibility font sizes
Height adjusts correctly between portrait and landscape
Technical requirements:
Extension binary size is under the platform limit
No crashes in any of Apple's test scenarios (cold launch, rapid typing, switching between apps)
App Group entitlements match exactly between main app and extension targets
No use of private APIs
Common rejection patterns to avoid:
Extension reading from UserDefaults.standard (Guideline 2.5.4)
Claiming Full Access is required when it isn't
Sending keyboard data to analytics without disclosure
Not providing a way to remove the keyboard from within the app
Include a brief review guide as a PDF attachment in App Store Connect with step-by-step instructions for the review team to enable the keyboard. This reduces "can't test it" rejections significantly.
Revenue Model: A Path to $7,000/Month
With a well-executed AI keyboard at $4.99/month, here's a realistic growth trajectory.
Average keyboard app conversion rate: 2-5% (higher than most utility apps due to strong value visibility)
At 3%: 2,000 subscribers requires ~67,000 total installs
At 5%: 2,000 subscribers requires ~40,000 total installs
Acquisition channels in priority order:
App Store Search: Optimize title for "AI keyboard", "smart keyboard iPhone", "autocomplete keyboard". The title tag carries 3-5× more weight than the keyword field.
Viral demos on social media: A 15-second video showing AI completing a complex email in 3 keystrokes gets shared because it's genuinely impressive. Post weekly on X and TikTok.
Niche communities: Target specific professional groups — "medical terminology keyboard for nurses" in healthcare Facebook groups, "legal boilerplate keyboard" in LinkedIn groups for paralegals. Niche fit dramatically reduces conversion friction.
LTV advantages of keyboard apps: Monthly churn for keyboard apps with a differentiated AI feature sits around 2-3%. Compare this to general utility apps at 6-8%. The annual plan conversion is also higher — users who've built muscle memory with your keyboard are willing to pay upfront for a year to lock in the price. Price the annual plan at 40% off monthly to maximize uptake.
Keyboard Extensions are genuinely hard to build — which is exactly why the opportunity exists. Most developers avoid them. Rork Max removes the largest implementation hurdles, and the remaining work (API integration, subscription setup, App Store submission) follows patterns that are well-documented in this guide. Start with a focused niche, ship early, and iterate based on user typing patterns. The developers who move fast in this category now will have defensible positions when the space gets more competitive.
Adding Multilingual Translation: Expanding Your Market from Day One
One of the highest-demand features for AI keyboards — and one that dramatically expands your addressable market — is real-time translation. A user typing in Japanese can tap a translation button and have their text instantly rewritten in English (or any of the 100+ languages Claude supports). This turns your keyboard from a single-language tool into something genuinely global.
The implementation adds a "Translate" button to the keyboard toolbar and a secondary AI call path:
// TranslationService.swiftstruct TranslationService { static func translate( text: String, from sourceLanguage: String, to targetLanguage: String, apiKey: String ) async throws -> String { let languageNames: [String: String] = [ "ja": "Japanese", "en": "English", "zh": "Chinese", "ko": "Korean", "es": "Spanish", "fr": "French", "de": "German", "pt": "Portuguese", "it": "Italian" ] let sourceName = languageNames[sourceLanguage] ?? sourceLanguage let targetName = languageNames[targetLanguage] ?? targetLanguage let systemPrompt = """ You are a professional translator. Translate the given text from \(sourceName) to \(targetName). Rules: - Output only the translated text, nothing else - Preserve the tone and style of the original - Keep proper nouns, technical terms, and brand names unchanged - If the text is already in \(targetName), return it unchanged """ let body: [String: Any] = [ "model": "claude-haiku-4-5-20251001", "max_tokens": 500, "system": systemPrompt, "messages": [ ["role": "user", "content": text] ] ] // Reuse the base request configuration from AIService guard let url = URL(string: "https://api.anthropic.com/v1/messages") else { throw AIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 15 // Translation needs more time than autocomplete request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode), let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let content = json["content"] as? [[String: Any]], let first = content.first, let translatedText = first["text"] as? String else { throw AIError.parsingFailed } return translatedText.trimmingCharacters(in: .whitespacesAndNewlines) }}
In the keyboard UI, wire translation to the current content of the active text field:
// In KeyboardToolbar.swiftButton(action: { guard hasFullAccess, let currentText = textDocumentProxy.documentContextBeforeInput, \!currentText.isEmpty else { return } isTranslating = true Task { do { let translated = try await TranslationService.translate( text: currentText, from: SharedDefaults.preferredLanguage, to: targetLanguage, apiKey: SharedDefaults.apiKey ) await MainActor.run { // Delete existing text and insert translation let textLength = currentText.count for _ in 0..<textLength { textDocumentProxy.deleteBackward() } textDocumentProxy.insertText(translated) isTranslating = false } } catch { await MainActor.run { isTranslating = false } } }}) { Label("Translate", systemImage: "globe") .labelStyle(.iconOnly) .opacity(isTranslating ? 0.3 : 1.0)}.disabled(\!hasFullAccess || isTranslating)
The delete-and-replace approach works for most text fields, but be aware that it doesn't work for password fields (textDocumentProxy.keyboardType == .asciiCapable && textDocumentProxy.isSecureTextEntry == true). Check for this case and skip translation in secure contexts.
Smart Templates: The Feature That Drives Word-of-Mouth
Beyond autocomplete and translation, smart templates are the feature that makes users tell their friends about your keyboard. A template is a pre-written text snippet with placeholder variables that the user can quickly fill in and insert.
Examples that resonate with specific niches:
For customer support teams: "Hello [NAME], thank you for contacting us about [ISSUE]. We'll resolve this within [TIMEFRAME]."
For salespeople: "Hi [PROSPECT], I wanted to follow up on our conversation about [PRODUCT]. Are you free for a [DURATION] call on [DATE]?"
For developers: "\n[LANGUAGE]\n[CODE]\n"
The implementation uses a template parser that detects [PLACEHOLDER] patterns and presents an inline mini-form for filling them in before insertion:
struct TemplateEngine { struct ParsedTemplate { let text: String let placeholders: [String] } static func parse(_ template: String) -> ParsedTemplate { let pattern = #"\[([^\]]+)\]"# let regex = try? NSRegularExpression(pattern: pattern) let range = NSRange(template.startIndex..., in: template) let matches = regex?.matches(in: template, range: range) ?? [] var placeholders: [String] = [] for match in matches { if let range = Range(match.range(at: 1), in: template) { let placeholder = String(template[range]) if \!placeholders.contains(placeholder) { placeholders.append(placeholder) } } } return ParsedTemplate(text: template, placeholders: placeholders) } static func fill(_ template: String, with values: [String: String]) -> String { var result = template for (placeholder, value) in values { result = result.replacingOccurrences(of: "[\(placeholder)]", with: value) } return result }}
Store user-created templates in SharedDefaults.customTemplates as JSON-encoded strings. Premium users can create unlimited templates; free users get 3. This is another effective paywall touchpoint that users encounter regularly, because templates are genuinely useful and the limitation is felt organically during daily use.
Performance Monitoring in Production
After launch, you need visibility into how the keyboard performs in real user environments — not just your test device. The main app can log anonymized performance metrics (no keystrokes) using a lightweight analytics solution:
Track these events directly from the main app's App Groups sync layer rather than from the Extension itself — Extensions have limited analytics SDK support and the memory overhead isn't worth it. Use App Groups to pass event data back to the main app, which then logs it.
The metrics to watch in the first 30 days after launch:
Full Access grant rate: What percentage of users complete the Full Access setup? Below 40% means your onboarding isn't explaining the value well enough.
AI completion latency: P95 latency should be under 3 seconds. If it's higher, investigate whether the Claude Haiku model is being rate-limited or if your network request configuration needs tuning.
Daily completions per user: This tells you whether users are actually using the AI feature. Below 2 per day suggests the suggestion quality needs improvement.
Free-to-paid conversion: The moment users hit the daily limit is your conversion event. Track how many paywall views convert, and what time of day conversions spike (this reveals when users are most actively using the keyboard and value the feature most).
With these metrics in place, you can make data-driven decisions about pricing, paywall timing, and feature development — which is what separates successful subscription apps from ones that plateau after the initial launch spike.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.