A while ago, on a bus, I watched a man try to tell the conductor where he needed to go. He pointed. He gestured. The conductor got impatient, the people behind him got impatient, and the man got smaller and smaller in his seat.
I leaned over to help, and that's when I realised he couldn't speak. He understood everything happening around him. What he didn't have was a fast way to be heard.
That moment turned into Tacit, an iOS app for people who can't rely on speech. You tap or type what you want to say, then either show it full-screen to the person in front of you or let the phone speak it aloud. It went live on the App Store on 29 September 2026, built solo for RevenueCat's Shipaton.
This is the full story: the problem, the product decisions, the architecture, the bugs that cost me days, the RevenueCat traps I fell into, and what I'd tell anyone building something similar.
People who can't rely on speech use AAC apps (augmentative and alternative communication). The reasons are wide: ALS, stroke, aphasia, autism, cerebral palsy, a laryngectomy, or temporary voice loss after surgery.
When I looked at what exists, almost everything was designed for children. Picture grids. Cartoon symbols. Bright primary colours. Those apps do important work for kids learning to communicate, but there was almost nothing made for grown-ups, nothing you'd want to hold up to a stranger at a pharmacy counter.
I also visited local NGOs that work with non-speaking people, and spent time listening to them and the people who support them. Two things stuck with me. First, how much patience it takes to be understood every single day. Second, that the hardest moments are rarely about vocabulary. They're about being rushed, being talked over, or being spoken to as if you can't understand.
So the product had to be two things at once: fast and dignified.
Early on I wrote one rule at the top of the project notes:
Two taps to be understood, from anywhere in the app. If a screen adds a third tap, the screen is wrong.
That rule settled more arguments than any mockup did. It's why there is no separate "compose" screen in Tacit. Writing a message and showing it happen on the same surface. Tap the box on Home and you're already typing in Show Mode; dismiss the keyboard and the same text becomes a sign.
It's also why the app has these pieces, and only these:
"Adult" turned out to be a long list of small decisions.
Type. I tried SF Pro Rounded everywhere first, because it feels friendly. At display sizes it read bubbly, which is exactly the childish look I was trying to escape. The final split: rounded type for the interface, plain SF Pro for the message itself.
A note while you write it, a sign when you're done. While composing, text sits at a calm 30pt. When the keyboard goes down, the message switches to a size ladder based on its length:
static func style(for text: String) -> Style {
switch text.count {
case ...14:
Style(
size: 78,
lineHeightMultiple: 1.02,
tracking: -0.018,
uppercase: false,
weight: .medium
)
case ...28:
Style(
size: 54,
lineHeightMultiple: 1.05,
tracking: -0.016,
uppercase: false,
weight: .medium
)
case ...60:
Style(
size: 38,
lineHeightMultiple: 1.12,
tracking: -0.012,
uppercase: false,
weight: .regular
)
default:
Style(
size: 30,
lineHeightMultiple: 1.18,
tracking: -0.010,
uppercase: false,
weight: .regular
)
}
}
Choosing a size wasn't enough, though. A text view then shrinks the type until both the height fits and the longest single word fits on one line. Without that second check, "PLEASE" happily wraps into "PLEAS / E", which is not something you want on a sign you're holding up to a stranger.
Motion. While Enhance is working, a field of tiny specks "breathes" around the message. Two earlier versions were rejected: an orbiting ring (it read as a loading spinner) and a left-to-right sweep (a direction gives the eye somewhere to go, which is the opposite of calm). The specks now hold still and swell toward the viewer on one slow 3.2-second breath. The message underneath is only lightly blurred, because blurring someone's words away at the exact moment they might need to show them is a bad trade.
Colour as wayfinding. When you create your own context, you don't pick its colour. A seven-swatch picker shipped first and got pulled: people picked the shade they liked, and two lavender tiles side by side make a grid you can't scan. The app now assigns the first palette colour no other tile is using. Emergency's red is deliberately not in that palette, so red always means "this is the alarm screen".
Tacit is native Swift and SwiftUI, with SwiftData for everything the user owns. The interesting part is the AI.
Enhance, the three-wording picker and the Today planner all use a language model. But for a communication app there is one failure that can't ship: "the AI wasn't available, so you couldn't say anything."
So Tacit has three engines and picks the best one the phone can actually run, every time:
static func best(isOnline: Bool = true) -> any PhraseAssistant {
#if canImport(FoundationModels)
if #available(iOS 26.0, *),
preferOnDeviceAI,
OnDeviceAssistant.isReady {
return OnDeviceAssistant.shared
}
#endif
if isOnline {
return RemoteAssistant()
}
return OfflineAssistant()
}
If the chosen engine throws, the resolver drops to the next one instead of showing an error. If every engine fails, the user's own words stay exactly as they typed them. An enhancement that eats your sentence and returns nothing is the worst possible outcome, so it's designed out.
This was the hardest product call. Supporting only iOS 26 on Apple Intelligence hardware would have kept every word on the device. But it's easy to conflate two different things: iOS 26 runs on iPhone 11 and newer, while Apple Intelligence needs an iPhone 15 Pro or newer. Even on iOS 26, most people don't have an on-device model.
So the fallback isn't an edge case. It's the common path. Tacit supports iOS 18 and later, and newer phones automatically use on-device AI through an @available(iOS 26, *) gate. The whole codebase compiles at iOS 18 with zero warnings, and I gave up nothing by doing it.
For the relay I compared providers on three things: privacy, output quality and cost. I went with Groq running gpt-oss-20b:
The single most useful thing I learned about small language models came from Apple's on-device model.
I first asked it, in plain prose, for three ways to say something. It returned one sentence. With a looser prompt it once wrote what looked like an ingredients label. So I moved to guided generation with a @Generable schema, and it still failed in a surprising way: I'd named the three fields short, direct and detailed, and for short the model optimised for brevity and returned fragments like "peanuts" and "Water".
Field names are part of the schema the model sees. Renaming the fields to describe their content fixed it:
@Generable(
description: "Three ways for a person who cannot speak to say the same thing to someone in front of them."
)
struct SuggestionSet {
@Guide(
description: "One full sentence stating the user's situation or need, in the first person. Example: 'I have a peanut allergy.'"
)
var statement: String
@Guide(
description: "One full sentence asking the other person for what the user needs. Example: 'Does this contain peanuts?'"
)
var request: String
@Guide(
description: "One or two full sentences giving the situation and the polite request together. Example: 'I have a severe peanut allergy. Could you check the ingredients for me?'"
)
var polite: String
}
"SHORT / DIRECT / DETAILED" survived only as display labels in the UI. For the note "allergy peanuts ask ingredients", the model now returns "I have a peanut allergy." / "Does this contain peanuts?" / "I have a severe peanut allergy. Could you check the ingredients for me?"
Two more lessons from the same work:
minItems wasn't enoughToday asks the model for six phrases covering someone's whole day. On the relay I use OpenAI-style strict JSON schemas, which constrain generation at the token level. My first schema was an array of six strings, and it sometimes came back with two. Strict mode doesn't honour minItems.
The fix was six required, named properties instead of an array. Taking the earlier lesson further, the slot names describe where in the day each phrase belongs (openingMoment, openingFollowUp, middleMoment, middleFollowUp...) rather than first to sixth, which would carry no signal. The function flattens them back into a list afterwards.
Two more things only testing caught:
reasoning_effort to "medium". On identical input that returned a Groq 400 on 3 of 6 calls; "low" succeeded 6 of 6 with no drop in quality. Raising the token ceiling didn't help, which ruled out a simple budget problem. Under a strict schema, a reasoning model that thinks too long fails loudly rather than returning something short.*Haunted House* with asterisks. These phrases get shown at up to 78pt to another person, so the function strips markdown and smart punctuation itself. Prompting asks; code guarantees.Apple's Translation framework runs fully on-device, which is great for privacy, but each language pack is roughly 200 MB. For someone standing at a counter who needs a translation now, a 200 MB download is a wall.
So translation has three modes:
Any language's offline pack downloads in one tap from the same screen.
A warning for anyone else building with this framework: translation can't be tested in any iOS simulator. Every language pair reports .unsupported. The device logs explained why: the simulator has no MobileAsset catalog, so it can't even ask Apple which models exist, and no translation model ships with any simulator runtime. There's no workaround. It has to be tested on a real iPhone.
AVSpeechSynthesisVoice(language:) returns the compact voice, which is the small, robotic one. That's the opposite of what you want when a phone is literally speaking for someone. Tacit enumerates every installed voice and ranks them premium, then enhanced, then standard, and always uses the best one. Novelty voices are excluded from the voice picker entirely. This is the voice someone asks for help in, not a joke shelf.
Tacit has no sign-up, no ads and no tracking. Your name, photo, saved phrases and My Card stay on your phone.
My Card holds the most sensitive information in the app: medical conditions, allergies, medications and emergency contacts. On most phones, Enhance and Today send typed text to the cloud relay. So My Card isn't merely "not sent". It's structurally excluded from every code path that talks to the network, and I keep a one-line check in the project notes:
grep -rn "IdentityCard" Features/Assist Features/Today Services/Relay.swift
That command must always return nothing.
Tacit started as free with a paid upgrade. Halfway through Shipaton I made it paid-only: monthly ($4.99) and yearly ($29.99), with a 14-day free trial on both. Every feature is included in the subscription, so there's no awkward free tier to maintain.
Making the whole app sit behind a paywall raises the stakes on everything to do with purchases. Here's what bit me.
1. A wrong entitlement ID fails silently. My code checked for an entitlement called pro; the dashboard said tacit_pro. Nothing errors. isPro is simply never true, forever.
2. Two different Apple keys, and I only uploaded one. The RevenueCat app page has two separate places for Apple keys. The in-app purchase key validates StoreKit transactions. The App Store Connect API key is what lets RevenueCat import products and read their state. I'd only uploaded the first. Every symptom I saw (products stuck on "Could not check", the yearly product impossible to import, an "Import products" menu that didn't exist) traced back to that one missing key. And after selecting it, you have to press Save, or the page reloads with the field empty and no warning.
3. An empty offering looks exactly like a broken paywall. My offering's packages pointed only at Test Store products, while the app ships with the App Store SDK key. RevenueCat resolves an offering against the app the key belongs to, so the app received an offering with no usable packages: a paywall with no prices and a dead button. Because the paywall gates the whole app, nobody could get in, including me. The fix was adding the App Store product to each package alongside the Test Store one. A package holds one product per app, so you add rather than replace.
4. A paying subscriber must never meet the paywall offline. Someone opening Tacit on a plane, in a basement or in a hospital may not reach RevenueCat in time. So the last known entitlement is saved on the device and used at launch, and a failed refresh never downgrades it:
private(set) var isPro =
UserDefaults.standard.bool(forKey: Entitlements.cacheKey)
var isLocked: Bool {
isConfigured && !isPro
}
The isConfigured part is an escape hatch too: a build with no RevenueCat key opens instead of locking everyone out of an app nobody can buy.
5. Don't hardcode the trial length. I changed the introductory offer from 7 days to 14 days in App Store Connect, and Apple kept serving the old offer for a while afterwards. Because the paywall reads the trial length from the live product rather than a string literal, it said "7-day" until Apple switched, then "14-day", with no rebuild and no resubmission.
6. The app price must stay Free. At one point the app's own price was set to $4.99. With the whole app behind a subscription, that would have charged people before they could even see the paywall asking them to pay again: the same thing sold twice, and a certain rejection.
7. Refactors can quietly delete your business model. The paywall cover in the root view is a single modifier. It got removed twice by unrelated changes, once in a commit about restoring a Skip button, once in a commit about preparing a TestFlight build. Nothing errored; the app just opened for everyone. Both times I found it with grep, not by using the app. I now keep a grep check for it in the project notes, the same way I do for My Card.
Reading window geometry inside body broke typing on iOS 26. To fade scrolled content under the status bar, I read the window's safe-area insets inside a SwiftUI body and masked the scroll view. On iOS 18 it worked. On iOS 26, on my own iPhone, the Today text box stopped accepting input: the placeholder stayed under the typed words and the button never enabled. The log showed AttributeGraph: cycle detected on every launch. Reading UIKit geometry mid-update forced a layout pass, and iOS 26 responded by dropping updates. The fix was to read the inset once in onAppear, and to never mask a view that contains a text field.
A drag-and-drop watchdog that was too eager. The context grid supports long-press-and-drag to reorder. Tiles lifted and floated, but never moved. The logs settled it: there's a gap of up to a second between the lift and the first drop event, and my watchdog was counting silence from the lift, so it cleared the drag state before the first event arrived. The fix was to distinguish two kinds of silence: reports that started and then stopped (a real drop, 0.35s) and no report ever arriving (an abandoned lift, 5s).
What worked:
What didn't:
Tacit went live on 29 September 2026, the day before the deadline. It's too early for meaningful numbers, and I'd rather not dress up a couple of days of data. What I do have is a working, shipped app that runs on iPhones people already own, works offline, keeps medical details on the device, and lets someone be understood in two taps.
Tacit means understood without being said. That's what I want for the man on the bus, and for everyone who can't rely on their voice.