Eight Seconds and a Fortnight
I wanted to be able to talk to my assistant from anywhere in the house, and while I was in there, to stop being woken up on days I don't work. Two small features. Three constraints that were more interesting than either of them.
Alexa gives you eight seconds
A custom Alexa skill can point at your own HTTPS endpoint instead of a Lambda, which is what you want if the answer has to come from the process that holds your history. What you don't get is time. Alexa abandons a skill response at roughly eight seconds.
My assistant's normal turn takes minutes. It runs tools, reads files, sometimes SSHes somewhere. There is no version of that which fits in eight seconds, and no amount of optimisation gets you there — it's a different kind of request.
So the Echo doesn't get the normal turn. It gets a fast lane: a small model, a 6.5-second budget, and a hand-off line when that isn't enough.
try:
# Amazon's ceiling is ~8s end to end. Leave room for the round trip
# and for Alexa's own speech synthesis.
reply = await _voice_turn(text, timeout_s=6.5)
except Exception:
reply = ("That one needs a proper turn, sir. I've put it in the voice "
"thread and I'll pick it up there.")
The important part isn't the timeout. It's that both halves write into the same conversation thread as the phone and the watch. Ask a question in the kitchen, and the follow-up is waiting on the couch. Without that, a voice surface is a novelty; with it, it's just another way into the same conversation.
The other thing worth doing properly: an unauthenticated skill endpoint is an open LLM on the public internet. Amazon signs every request, and verifying that signature is the authentication. All of it, not the easy half — the cert chain URL's shape, the leaf's validity window, the echo-api.amazon.com SAN, the chain to a trusted root, RSA-SHA256 over the raw request bytes, and a 150-second timestamp window. That last detail bites people: you have to read the body before you parse it, because the signature covers the exact bytes, not your re-serialisation of them.
Never compute a fortnight from week numbers
The alarm was two dumb schedules: a watch alarm at 06:45, and a routine that shuffled music on a speaker at 06:50, both firing every Mon–Fri. I'm off every other Friday. One Friday in two, the house woke me up for nothing.
The obvious implementation is ISO week parity — even weeks on, odd weeks off. Don't. ISO weeks reset awkwardly across some year boundaries; a 53-week year flips the parity, and your alarm quietly starts waking you on your day off some Friday in 2028. You will not connect that to code you wrote in 2026.
Anchor it to a known-good date and count days:
def is_off_friday(d: date, anchor_iso: str) -> bool:
if d.weekday() != 4:
return False
anchor = date.fromisoformat(anchor_iso)
return (d - anchor).days % 14 == 0
Day arithmetic from a fixed point can't drift. The anchor is one real Friday I actually had off, which also means the config is readable by a human six months later — which "even ISO weeks" is not.
The rest is the part that makes it worth owning: one-off skips, a dismiss that stops the escalation instead of making you ride it out, and a check against the calendar so a day marked as leave suppresses the alarm without being told twice.
Siri will not learn "ask X anything"
I assumed the marquee phrase was free. It isn't.
phrases: [
"Ask \(.applicationName) \(\.$message)", // does not build
]
error: Invalid parameter type. AppEntity and AppEnum are the only
allowed types for message
App Shortcut phrases can only interpolate parameters that are entities or enums — a fixed, enumerable set. An open-ended question is neither, so Siri physically cannot take arbitrary free text in one utterance from a phrase. You get two steps: invoke, then dictate.
Alexa can, because AMAZON.SearchQuery exists as a slot type and does exactly this. So the single-breath surface turned out to be the speaker in the kitchen, not the phone in my pocket — the opposite of what I'd have guessed before starting.
I left the finding in the source next to the phrases array rather than in a commit message, because the next person to have that idea will be me, and I'll be looking at the array.
Speaking replies, but only when someone's wearing something
Last piece: read replies aloud on AirPods. The temptation is a plain on/off toggle. That's wrong — a phone that starts talking out of a pocket is a phone you turn the feature off on, permanently, about four minutes after enabling it.
Gate it on the audio route instead. Speaker playback is a decision about the room; headphones are a decision the user already made by putting them in.
private static let headphonePorts: Set<AVAudioSession.Port> = [
.headphones, .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .headsetMic, .usbAudio
]
Two details that turned out to matter more than the synthesiser:
Pulling the buds out mid-sentence must stop the utterance, not reroute the rest of it to the speaker. That's a route-change observer, and it's the exact failure this feature exists to avoid.
Scrub the text. A reply that reads well in a chat bubble is full of markdown, fenced code and URLs, and a speech synthesiser will cheerfully pronounce every backtick and slash in a forty-line diff. Code blocks become "code omitted", URLs become "a link", and anything past about 1200 characters gets cut with "the rest is on screen".
On the Mac the same feature needs an entirely different route check — there's no AVAudioSession, so "are headphones connected" becomes "is the default output device on a Bluetooth transport", asked of the CoreAudio HAL. Worth noting it's deliberately narrow: built-in speakers and the headphone jack both report BuiltIn, so a plugged-in cable is indistinguishable from the speaker grille. Guessing wrong there means the computer starts talking out loud in a room with other people in it, so it doesn't guess.
What I'd do differently
Probe the platform constraints before designing the feature, not after. I had a mental model where Siri was the natural home for single-utterance questions and the Echo was the afterthought, and I'd written most of the iOS side before the compiler told me it was backwards. Ten minutes of reading Apple's parameter rules would have reordered the whole build.