A keyboard is a hostile place to build
Most of my products are Flutter apps. Keyboarda is native Swift and SwiftUI because of where it runs. A custom keyboard on iOS is an app extension: a separate process that the system starts whenever the user switches to it, with a tight memory budget, no network until the user grants Full Access, and no direct access to the document it is editing.
That last constraint is the product's central problem. A writing assistant has to read what the user wrote, and a keyboard is not allowed to.
Reading text you can only see through a keyhole
A keyboard reads the host app's text through UITextDocumentProxy, which returns a short run of text before the cursor and a short run after it. For a one-line message that is enough. For a paragraph with line breaks, it is a keyhole.
So Keyboarda reads the field by moving through it. The proxy service walks the cursor to the top of the document, stepping across the paragraph boundaries where the proxy tends to go blind, then walks back down in chunks of up to two hundred characters, working out from the before-and-after snapshots how far the cursor actually moved and collecting exactly that much. Writing the result back is the same problem in reverse: walk to the end, delete in batches, check for anything that survived, then insert the new text. Every step yields to the run loop so the host app can process the change, because the proxy talks to another process and its state lags behind the calls made against it.
It is the least glamorous code in the app and the reason the app works.
Showing the edit, not just the answer
An AI rewrite that silently replaces a message is a rewrite people stop trusting after the first time it changes something they meant.
Keyboarda lays the rewrite over the original as a word-level diff: removed words struck through, new words underlined. The diff is a longest-common-subsequence comparison over tokens that keep their whitespace, so the text reassembles exactly. Adjacent removals and additions are grouped into a single change, and each change can be flipped back to the original on its own before the user taps Use Text. The text that gets inserted is composed from those choices, not taken wholesale from the model.
The diff also knows when not to appear. A translation, a summary or a table is a different text rather than an edited one. A short intent like "ask Sam to move the meeting to Friday" is expanded into a full message, which makes a diff against six words meaningless. And when a rewrite shares no words with the original at all, the diff would be one long strikethrough. In each case the keyboard shows the plain result instead.
One user across two processes
The app and the keyboard are two processes with no shared memory. They share state through two deliberate channels: an App Group for settings, custom styles, tool order, email profiles, the synced daily limit and the review-prompt counters, and a shared Keychain access group so Firebase Auth resolves to the same user in both.
That second channel was a fix, not the first design. Originally each process signed in anonymously on its own, and the backend accepted a user ID from the client to reconcile them. Sharing the Keychain made both processes one authenticated user, which let the backend trust only the ID in the verified token. It also halves the anonymous-auth user count, which is the second-largest line on the Firebase bill once the product grows.
The backend, and the audit that shaped it
Early builds called Gemini directly from the device, which meant the key shipped in the app. The backend exists to move it: a callable Cloud Function in TypeScript holds the key in Secret Manager, and the app never sees it. Prompt construction deliberately stayed on the client, so tuning a style's wording is an app change rather than a function deployment, while the function enforces what matters: who is asking, how much they have used, and how long the prompt is.
Before launch I audited that architecture and costed it at four scales. The model is the dominant cost at every one of them, which made the rate limit the part that had to be right. The shipping function takes the user from the verified token and nowhere else, reserves the day's slot inside a Firestore transaction before calling the model, refunds it if the call fails, rejects oversized prompts before any quota is touched, and reads both limits from Remote Config, cached for an hour so the lookup is not paid on every request. A scheduled function clears usage records older than a week, and the security rules let a user read their own usage and nothing else.
Around it: RevenueCat for the subscription, keyed to the Firebase user so the webhook and the token agree on who is premium; Remote Config synced into the App Group so the keyboard knows the current limit without fetching it; a review prompt that waits until the keyboard has completed enough successful requests; and Firebase Analytics.