← Blog

The keyboard is a remote control

Outspoken ships an iOS keyboard that dictates. Where the letters would be there's an orb, a status line, a globe key and a delete key. Hold the orb, speak, and the words land at your cursor.

It never records you. It can't.

Why a keyboard can't record

iOS blocks audio capture in every extension process, and it refuses quietly. You ask AVAudioEngine for its input node and get back a format whose sample rate is 0 Hz. Nothing throws. Install a tap on that node and the process dies. There's no entitlement to request, no Info.plist key, no background mode that changes the answer. Capture is something an app can do and an extension cannot. AudioCapture checks the format for 0 Hz before it touches anything and reports needsApp, which is the whole of what a keyboard is able to do about the problem.

So the keyboard drives the app instead. Tap the orb and a session starts in the container app, which records, transcribes with whichever engine you picked, and hands back finished text. The keyboard's job is to ask for that, watch it happen, and put the result where your cursor is.

One file, two processes

Everything it knows, it reads from one file: a JSON document in the App Group both processes share. While a session runs the app writes phase, audio level and volatile text into it and stamps a heartbeat once a second. The keyboard polls it ten times a second, which doubles as the feed for the waveform you're watching. When the app finishes, the final text goes into the same file, and the keyboard inserts it through textDocumentProxy and clears it.

The file being the only source of truth is a rule, not an accident. The two processes can also talk through Darwin notifications, and it's tempting to build the whole flow on them. They carry no payload, which is fine, and they arrive instantly, which is also fine. What they are not is reliable. A notification posted while the receiving process is suspended is dropped, and for a keyboard extension suspended is the normal state. So a Darwin post here is a wake-up and nothing more. It means: go and read the file. If it never arrives, the next poll finds the same information a fraction of a second later.

Three ways to start a session

Starting a session is the part with the most machinery, because an extension has almost no way to make another process do anything. There are three routes, tried in order, each escalating when its deadline passes with no state file written.

The first is a plain command posted to the app, which is already running in the background. The keyboard waits two seconds and re-posts every half second inside that window, because the posts are droppable. It only tries this at all if the app's keep-alive beacon is less than fifteen seconds old, since a command to a dead process is a two-second stall and nothing else. When it works, nothing on screen changes. No app switch, no animation, you are still in Messages.

The second opens a Shortcut wrapping an AudioRecordingIntent, which can cold-launch a quit app in the background where a command can't. It needs the user to have created the Shortcut, so it's off by default, and it gets ten seconds.

The last route is a redirect. Opening a URL is the one capability the system does leave a keyboard, so it opens outspoken://dictate?session=… and the app comes to the foreground on a deliberately empty hand-off screen — no waveform, no mic, no stop button, because the dictation interface lives back in the keyboard. Recording starts immediately so nothing you say while swiping back is lost. That pass also re-arms the mic and the keep-alive, so the next tap takes the first route and you don't see the app again.

The mic that never goes cold

The first route deserves an explanation, because on paper it shouldn't work. iOS will not start microphone I/O for a backgrounded app, which is exactly what it's asking for. What iOS never objects to is capture that is already running. So after a session ends, the app doesn't stop the mic. It keeps the input running and stops looking at the buffers, discarding every one unexamined, for a ten-minute idle window. A command-route start just opens a new segment on a mic that never went cold. The orange recording indicator stays lit for that whole window, which is the honest cost, and the behaviour is a switch in Settings.

That only helps while the process is alive to hold the mic, and iOS suspends an app producing hardware silence. So the keep-alive renders noise at −86 dB rather than writing zeros. Inaudible, never bit-zero, and the system counts it as real audio work.

Only the keyboard on screen

The last piece is a lifecycle rule that took a while to see. iOS doesn't hand an extension one UIInputViewController. It accumulates detached ones in the same process, all still alive, each holding a textDocumentProxy wired to nothing. A detached instance that consumed a finished transcript would clear the state file and insert into its dead proxy, and the text would vanish while the keyboard you were looking at saw nothing happen. So a single flag, isAttached, gates sync, poll, consume and insert. Only the instance on screen may touch the relay.

None of this is the app I set out to write. I wanted the keyboard to record. It isn't allowed to, so the recording moved to the one process that is, and the keyboard became a remote control for it.