Inspiration
Somebody sends a seven-minute voice note and the whole message is in there somewhere: two things you have to do, one date, and a question you are expected to answer. WhatsApp will transcribe it for you, but a wall of unpunctuated text still does not tell you what to do about it. And a personal voice note holds more than most people would hand to a server: a diagnosis, a salary offer, a falling-out with a business partner. So the app had to do the reading, and it had to do it without the audio ever leaving the phone.
The second half of the idea comes from where we live. A Spanish voice note arriving on a German phone, answered in Spanish, is not an edge case in this market; it is Tuesday. So the language a message is spoken in is measured rather than assumed, and the language the answer is written in is the user's own setting, defaulting to whatever was spoken.
What it does
You forward a voice note into AlGrano from the share sheet, or import an audio file from the app. It transcribes the audio on the device, works out which language was actually spoken, and turns it into three to five key points, the tasks aimed at you, and the dates that were genuinely named, plus the full transcript, open by default, so you can check a sentence. Underneath sit three reply drafts with the same content in different tones: diplomatic, neutral, firm. Tap one, it lands on the clipboard, you paste it into your own chat and send it yourself. To-dos move into Reminders, a named date moves into the calendar.
Above the reply cards is one compact line, "To: Ruth · du": an optional sender name and how the reply should address them (automatic, familiar, polite). Changing either rewrites the three drafts. The picker is simply absent when the output language is English, because English has no familiar and polite form to choose between, and a line the template has no rule for only costs a small model attention.
The app is multilingual on three independent axes. The interface ships in German, Spanish and English and follows the system language (switchable per app through iOS's own language setting, with no picker of our own); the input language is detected from the audio, never assumed; and the output language is a setting whose default, "auto", means "answer in the language that was spoken".
A quick search over the saved results matches titles, sender, the full transcript, bullets and to-dos. It builds no index and writes no file: it filters what is already on screen and nothing outlives the query. Deleting a result deletes its audio copy with it. A run in progress can be cancelled, and the shared voice note stays in the inbox so you can run it later. Since 1.2 the appearance (system, light, dark) is a choice for this app alone, and a quiet card at the top of the home screen says when a newer version is on the App Store.
The app has no record button and never asks for microphone access. No account, no analytics, no cloud, no sync. It all works in airplane mode, and it is free: the three tips unlock nothing.
How we built it
Swift 6 with strict concurrency, SwiftUI, SwiftData, deployment target iOS 26, project defined in XcodeGen. 179 tests in the app's own target, on top of the suites the shared chassis carries.
The pipeline is two model calls, and the split is the point. Transcription runs on Apple's on-device speech recognition; the summary and the three replies come from Apple's on-device language model with guided generation, in two separate steps, and the result is saved after the summary and before the replies. A message worth answering firmly is exactly the kind of message whose content can trip the safety guardrail, so a refusal costs the drafts and nothing else: the summary stays on screen, with a hint and a "Try again" rather than a crash or an empty screen. The same is true of the three availability states of the model (available, device not capable, Apple Intelligence turned off), which each get a card instead of a dead end.
Detecting the language costs passes, not a flag. Apple's speech transcriber takes one locale per run and identifies nothing on its own, so the repo rule "input language is detected, never assumed" has to be paid for explicitly: cut a 20 second prefix, transcribe it once per candidate, score the outcomes, then transcribe the whole recording with the winner. Scoring needs two signals, and the measurement says why. On a matrix of German, Spanish and English fixtures against every candidate model, the recogniser's own confidence points the right way every time, but the English row is nearly a tie (0.905 / 0.925 / 0.969), because English words survive a German or Spanish acoustic model well enough to score 0.9. A language identifier run over each probe transcript breaks the tie. Two economies keep the price honest: probes are capped while the final pass is not, so a five-minute message pays for three times 20 seconds rather than three times five minutes, and a short note (the typical WhatsApp voice message) is covered whole by the probe, so the winning probe is the transcript and is reused rather than re-run.
A four-minute message does not fit in the context window. Above roughly 700 estimated tokens the transcript is chunked, summarised piece by piece and merged back. The chunks overlap on purpose, so the same fact arrives twice by design and deduplication is the price of the overlap rather than a nicety. Bullets are interleaved before the cap is applied (the first bullet of every chunk, then the second of every chunk), so five bullets over six chunks still describe the whole message instead of the first minute in detail.
Prompt hygiene is structural. Templates are versioned files under Shared/Prompts//, not string literals. The transcript never enters the instructions; it goes into the user prompt only. So does the sender's name, on its own labelled line, because it is user text. The reply tone and the address form are app-controlled tokens rather than user text, which is the only reason they may be template parameters at all.
The share extension is deliberately lazy. It runs under a memory budget that a speech analyser and a language model would blow straight through, so it writes the bytes into an App Group inbox, posts one quiet local notification, and does no work at all.
The purchase layer. RevenueCat sits in front of StoreKit 2 for the three tips (a coffee, a tapa, a dinner), and it has done so since the shipping 1.0. There is no unlock in this app and no paywall anywhere: nothing is behind money, so there is no entitlement to read, and the unlock half of the pattern was deliberately left out rather than copied for symmetry. Products are fetched by identifier rather than through an offering, because an offering is a dashboard object and a second web form between customer and purchase is a second thing that can be wrong on activation day. There is no RevenueCat paywall UI; the tip sheet is our own. The SDK is pinned to an exact version (5.83.2) and linked into the app target only, so the share extension never sees it. Privacy is kept literally: automaticDeviceIdentifierCollectionEnabled and diagnosticsEnabled are both set to false (the first defaults to true, and a default is a promise nobody can check), the app user id stays nil so the SDK issues an anonymous one instead of deriving anything from the device, and the SDK cache lives in its own defaults suite, not beside the app's own keys and expressly not in the App Group the share extension reads. The app's privacy manifest declares no collected data types; the only category the store listing carries is purchases, and it is not linked to the user.
The StoreKit fallback is complete and not negotiable. With no key everything runs through the plain StoreKit 2 store, exactly as it did before the SDK existed, with no SDK configured and no network call to RevenueCat. Three roads lead there and all three are tested: no entry, an empty entry, and an unsubstituted build variable, which would look like a valid key, would be accepted, and would fail in front of a customer instead of in a build. The fourth case, key present but package absent, resolves to StoreKit through canImport. Simulator and CI therefore never fail for want of a key.
Since 01.09.2026 that layer is not AlGrano's any more: it lives in the shared chassis in two halves, one free of the payment SDK and one containing it, so an app without purchases simply does not link it. What stayed in AlGrano is three strings and an explicit "no unlock product", which turns a decision into structure instead of repeating it.
Challenges we ran into
A required field is a field the model will fill. TestFlight build 1 met a real Spanish voice note in which the speaker recounts a family situation at length and asks the listener for nothing whatsoever. The model returned every statement attributed to the wrong person, seven "to-dos" that were the key points said again, and two dates: the reference date we had injected, handed back as though the message had named it, and a "Friday" that fell on a Monday. Build 2 did the same thing one field along, filing small talk as appointments. We follow an escalation ladder now, in order (prompt, then the field guide, then making the field optional, then deterministic code), and the fix that actually worked was a definition rather than a heuristic: an appointment is something the message names a day or a time for. The deterministic pass that runs afterwards stays conservative on purpose. When in doubt the item stays, and a wrong date loses its day rather than being corrected into a guess.
The share extension is the product, and it can do neither of the two things it needs. It cannot process the audio, because of the memory budget, and it cannot open its containing app: opening a URL is documented as unavailable to share extensions, and the responder-chain trick that gets around it reaches for a private selector, which is not something to hand to App Review. So the handover became a local notification, and then that notification turned out to be silent in practice, because provisional authorisation is not a banner. The app now earns real permission on first launch, before the first share ever happens.
And the blocker at the top of the risk list was a belief, not a fact. WhatsApp exports voice notes as Opus in an Ogg container, and everything we had written down said iOS cannot demux Ogg, which pointed straight at writing our own Opus decoder and taking on the portfolio's first third-party dependency. Instead we made it the first milestone and measured it with ten probe tests against byte-crafted fixtures: iOS 26 opens Ogg-Opus natively. No transcode, no decoder, and our own documentation was the thing that needed fixing.
Cross-language runs are the normal case, so they are the test matrix. A Spanish message on a German phone answered in Spanish, an English message answered in German, the interface in a third language throughout: each of those is a combination of three independent axes, and the acceptance criteria cover the matrix rather than a happy path. The one thing the app still cannot do is the mixed-language recording, because the transcriber commits to a single locale for the final pass.
The rest was ordinary weather: our first submission drew Apple's standard information request for a new app on a young account, about eight hours after submitting, costing roughly a working day before 1.0 went live.
Accomplishments that we're proud of
The app never asks for the microphone, because it genuinely does not need it: audio arrives through the share sheet or a file you import, and nothing else. That is what makes the privacy promise true rather than aspirational. Every model output is marked as an AI suggestion (EU AI Act Art. 50) and loses the label the moment you edit it, and a reply is copied rather than sent: the manual paste is the architecture, not a disclaimer, and it is deliberately the only way out. Deleting a result deletes its audio copy too, because an orphaned file in a shared container would break the promise quietly. The quick search was built to add nothing to the machine, which is the only shape a search can take in an app whose pitch is that the message stays on the phone. And the app knows when the long road ran: a result carries the number of chunks it took, so "the four-minute note really did go down the chunked path" is something a test asserts rather than something we hope.
What we learned
Optionality is a stronger instruction than prose. Making the to-do list optional did more than three carefully written sentences ever did, and negative examples in a template only anchor a small model on the thing you told it to avoid.
Confidence alone is not language identification. The measurement that showed English scoring 0.9 against a German acoustic model is the reason there are two signals instead of one, and it is also the reason we no longer trust a single number that happens to point the right way on the fixtures we had.
Porting a payment integration into a second app is where you find the first one's bug: we had been deduplicating tip transactions against the store's identifier in one place and RevenueCat's own in another, two namespaces that never meet, which would have thanked a supporter twice. The same pattern reached four apps before we moved it into the shared chassis, and the move deleted the last copy of a stale comment that had already sent one session chasing a fix that no longer existed. A comment about another app's code ages unread.
Renaming an app is free right up until the first upload and permanent afterwards; ours happened in time, but the retired store record can never be deleted, and its dead id was still sitting in our own public store links a week after we walked away from it. And the thing you are most afraid of deserves to be measured first, not last.
What's next for AlGrano: Voice Note Summary
Correcting the detected language on the result screen ("that was Spanish, not German") is still the first candidate and still not built: the detector already returns a ranking and the transcriber already accepts a fixed locale, so what is missing is the affordance rather than the machinery. It matters because a mixed-language recording still gets exactly one language.
After that: playing the audio and scrubbing to the matching spot in the transcript (the transcript segments already carry their start and duration, the player does not exist), regenerating the replies for a result opened from the list rather than only for the run in progress, and a "delete everything" row in Settings next to the per-result deletion that exists today.
Built With
- apple-foundation-models
- eventkit
- revenuecat
- speech
- storekit
- swift
- swiftdata
- swiftui
- xcodegen

Log in or sign up for Devpost to join the conversation.