Spoken morning briefing on an Echo, without writing a skill
I wanted a JARVIS-style morning briefing — calendar, weather, finances, todos, the day's notable anniversaries — spoken out loud on the Echo Studio in the living room. Triggered by me waking up, not by a fixed alarm.
What was happening
A separate side project of mine, a household-safety tracker, already writes an "activity event" to DynamoDB whenever it detects movement after a long quiet stretch — that's a pretty reliable "I'm up" signal. So the wake detection was solved before I started.
The play-it-out-loud part wasn't. I didn't want to publish a public Alexa skill for one user and one device. I wanted to push text into the speaker the same way I'd push to a chat client.
What I found
Two surfaces inside the Alexa cloud get close, neither perfectly:
-
The announcements API lets you push text to any of your Echo devices via the unofficial
alexa_remote_controlcookie flow. It works — but Amazon silently drops announcements longer than ~600 characters. My briefings run ~880 characters, so the speaker would chime, say nothing, and move on. -
Routine-based
Alexa.Speakdoesn't have the length limit. You build a routine triggered by a custom utterance, with a single "speak" action, then invoke that routine programmatically.
The routine path was the unblocker. The whole pipeline ends up being:
DDB stream on activity events
→ Lambda webhook (filters first event after 03:00 local)
→ POST /api/morning-briefing on the assistant
→ ~140-word briefing (calendar, weather, finances, todos, etc.)
→ POST to Alexa routine via alexa_remote_control
→ Echo Studio speaks the briefing
Idempotency is a single DDB key per user per day
(briefing_played:<user>:<YYYY-MM-DD>) so a noisy morning doesn't
result in three back-to-back briefings.
The fix
The Lambda filter is just:
def is_first_qualifying_event(event):
activity = event["dynamodb"]["NewImage"]
user = activity["user_id"]["S"]
ts_local = to_local(activity["ts"]["N"])
if ts_local.hour < 3: # ignore overnight noise
return False
flag_key = f"briefing_played:{user}:{ts_local:%Y-%m-%d}"
if ddb_get(flag_key):
return False
ddb_put(flag_key, ttl=86400)
return True
And the failsafe — a systemd timer that fires every ten minutes between 04:00 and 09:50 local, with a hard 10:00 catch-all — runs the same code path. So even if the activity tracker is down, the briefing still plays.
What I'd do differently
The 600-character announcement limit cost me an hour before I figured out what was happening. The API returns success and the device chimes; only the spoken content is dropped. I'd add an "echo back what was actually said" assertion the next time I rely on a cloud TTS surface — fire the request, then poll the device's activity log for the spoken text, and alarm if it's empty or truncated. Silent failures in voice pipelines are uniquely infuriating because you can't visually scan past them.