A watch app in a day, and three things Apple doesn't tell you
// 2026-09-21 · Frederic Haddad · 7 min read
The meeting recorder I run is a web page: open it on a phone, press record, 30-second WAV chunks stream to a Mac at home, and when I press stop the whole file is diarized and the note lands in my vault. It works. It also means taking the phone out, unlocking it, finding the tab. I wanted one button on my wrist.
Two days later the watch app also had a second page: hold to talk, and the same voice agent that answers on the web page (the one that makes phone calls) — speech to text, a local model, a read-only executor over my files, a cloned voice — answers on the wrist. This post is about the three watchOS facts that cost most of the time, and the network shape that made it possible without an iPhone in the loop.
The shape: the watch is not on my network
A watch cannot join a Tailscale tailnet. There is no watchOS client, and the phone's VPN tunnel does not extend to the watch. So the private URL the web page uses was never going to work.
What does work is a public HTTPS endpoint with a token. Tailscale Funnel publishes one path from one port on the Mac to the internet; the app carries a long random bearer token baked into a git-ignored Config.swift at build time; the endpoint is a tiny separate listener on loopback — not the main app — with a constant-time token compare, a per-IP rate limit and a 12 MB body cap. Only those routes are reachable from outside. When the second page came, it got a second path, a second token (this one can ask questions of the vault, the first can only add audio; a leak of one should not open the other), and its own tiny listener.
The app is installed from Xcode on my own Mac with automatic signing. No TestFlight, no store. One device.
Thing one: the audio engine tap returns silence
The first recorder used AVAudioEngine with an input tap — the standard way to get PCM buffers. On the watch it produced files of exactly the right length containing nothing. No error, no permission prompt beyond the normal one, just zeros.
Every watch voice-memo app I could find uses AVAudioRecorder — file-based recording — and so does mine now: 16 kHz mono 16-bit WAV, a new file every 30 seconds, each queued for upload the moment it closes. File first, network second: a dead connection delays a recording, it never loses one.
Two related surprises. The first audio-session activation after launch takes about five seconds (route negotiation with the phone), so the app warms the session when the screen appears rather than when the button is pressed. And prepareToRecord/record can block for seconds on the watch, so they run off the main thread, with a 15-second timeout that says so instead of a frozen button.
Thing two: watchOS refuses your WebSocket
The voice page on the web streams microphone PCM over a WebSocket and gets PCM back. The natural port to the watch was the same protocol: URLSessionWebSocketTask, one socket per conversation. It compiled, it ran, and every connection failed with The Internet connection appears to be offline — while the recorder on the other page was uploading happily over the same network.
The real message was further down the log: Path was denied by NECP policy. First over the Bluetooth link to the phone, which I could believe — that relay is HTTP only. Then with Bluetooth off and the watch on Wi-Fi: the same denial. watchOS does not let a third-party app open a socket. URLSession HTTP tasks, yes; streams and WebSockets, no, on any interface.
So the talk page is plain HTTPS: POST /say with the utterance as one WAV, then a long-poll GET /events?after=N&wait=25 that returns as soon as there is something new — transcript, status lines while the executor works, the reply text, and the reply audio as base64 WAV chunks with sequence numbers. The cost of the transport is one request plus an already-open poll; the reply starts playing at the same moment it would have over a socket. Measured end to end for "what time is it": 13.7 s over the socket, 13.7 s over the poll.
Two consequences turned out to be features. Events carry sequence numbers, so a wrist-down suspension mid-turn loses nothing — the next poll resumes from the last one seen. And the relay had to become one more "browser" to the existing voice agent, using its typed-text turn; the agent itself did not change by a line.
Thing three: one background session per app
A watch app gets background execution through a WKExtendedRuntimeSession. The recorder starts one when recording begins so the wrist can go down; the talk page started one per turn so the reply would arrive. Each page managed its own.
watchOS allows one such session per app. When the talk page ended its session after a reply, it invalidated the recorder's. The symptom was two days in: a meeting recorded right after a voice turn stopped ticking whenever the wrist went down, and after pressing End the upload queue sat there with nothing to send it — no background time. Nothing in either page's code was wrong on its own.
The fix is a keeper: one shared session with reference counting. The recorder holds it from Record until the last chunk is uploaded after End (including across pauses — chunks may still be sending); the talk page holds it only while a turn is pending. Nobody invalidates while another holder remains, and if the system ends the session while it is still needed, a fresh one is started. The talk page also refuses to record while a meeting is being recorded — the microphone and the audio session belong to the recorder then — and starting a meeting ends any voice turn in progress.
Smaller things, for the next person
- Pause is not stop. My first version ended the meeting when the big button was pressed again, which made a second meeting. Now the button is record / pause / resume; a separate End closes the meeting.
- Keep the session until the queue is empty. The first real 40-minute meeting uploaded 79 chunks in real time and then the stop marker sat for 26 minutes, because the app was suspended the moment End was pressed. The recorder now keeps its session until the upload queue drains, up to 60 seconds.
- Play replies gaplessly. A player per audio chunk re-activates the audio session for every 2-second chunk and cuts at each boundary. One
AVAudioEngineplayer node with buffers scheduled back to back, a 1.2-second pre-buffer, and the session activated once per reply off the main thread. - Wrist-down is not leaving. Scene phase
inactiveis the screen dimming;backgroundis leaving the app. Ending the conversation oninactivemade a wrist flip cancel the reply. - Never replay old audio. The relay keeps events for late polls; on relaunch the app's counter reset and it replayed the whole session's audio. Now the relay drops unplayed audio the moment a new turn starts, and never serves audio older than 60 seconds.
What it does, honestly
Recording: one button, real-time chunk upload, the note in the vault with names on the lines a few minutes after End. Talking: 3–15 seconds per turn depending on how much the executor has to search, no barge-in, replies read aloud through the watch speaker or AirPods, answers that survive the wrist going down. Good for "what's on my calendar at three", "what did we agree on that call", "add a task" — not for the flowing conversation the web page gives.
The remaining wish is background uploads that survive a suspension mid-pause. That is a URLSession background configuration and a day I have not spent yet.
Receipts
- Chunks: 16 kHz mono 16-bit WAV, 30 s each; 79 chunks for a 40-min meeting, uploaded in real time.
- Funnel: two paths on one port to two loopback listeners; bearer tokens, constant-time compare, 240 req/min/IP, 12 MB cap (recorder), 4 MB per utterance and 30 turns/min (talk).
- WebSocket:
Path was denied by NECP policyover Bluetooth and Wi-Fi. HTTP long-poll: same 13.7 s end to end on the reference turn. - Runtime session: one per app; the shared keeper fixed a paused timeline and a stalled upload queue.