Martin Harris

"Ceci n'est pas une API Gateway" — René Magritte (paraphrased)

Making cloud abstractions tangible through physical hardware.

Chat with me →

Alexa, Announce 'Hello, World!'

Alexa, Announce 'Hello, World!'

A row of small smart speakers on kitchen, bedroom and shelf surfaces, each glowing with a cyan ring, while a single line of light travels between them from a laptop in another room

Echos have a party trick: "Alexa, announce 'dinner's ready'", and every speaker in the house chimes and says it. I live alone, so I've never had much use for it. The cats ignore me wherever my voice comes from.

But I do spend a lot of my day running several Claude Code sessions at once, and they spend a lot of theirs waiting for me to approve something. They already chime and talk at me through the Mac, which is fine when I'm at the Mac. A house that tells me, wherever I am, that a session needs me would be genuinely useful. So I asked one of them a simple question:

On my Amazon Echo, I can say "Alexa, announce 'Hello, World!'" and it will play from every Echo in my house. Can you do that?

The answer, at about four o'clock on a Saturday, was no.

Amazon would rather you didn't

There is no public API for announcements. Third-party skills can't make them. Amazon reserves that particular trick for itself, presumably because the alternative is every skill developer in the world being able to make your kitchen shout at you.

The well-trodden workaround is Voice Monkey. The free tier gives you five speakers and 200 requests a month, which is roughly six a day — fine for "the washing's done", less fine for a handful of AI sessions that each need me several times a day.

So, could I build my own? Voice Monkey's docs explained exactly how it does it, and it's wonderfully baroque:

  1. A smart-home skill exposes a virtual sensor.
  2. An Alexa routine fires when that sensor "detects" something.
  3. The routine opens a custom skill on one specific Echo.
  4. The custom skill reads the queued message and speaks it.

One routine per Echo. And the punchline: the only way a routine can make a real announcement — chime, every room in sync — is with its built-in Announce action, which only speaks text you typed into the Alexa app. Dynamic text gets per-device speech instead. Close, but not "Alexa, announce".

Pretending to be my phone

Home Assistant users have had proper announcements for years, via the Alexa Media Player integration. Underneath it is a Python library called alexapy, and its approach is refreshingly direct: it registers itself on your Amazon account as the Alexa app, then calls the same private endpoint the app uses when you announce from your phone.

POST https://alexa.amazon.co.uk/api/behaviors/preview

The body is a one-step routine of type AlexaAnnouncement, with your text and a list of target devices. That's it. Everything difficult lives in the login.

The obvious place to run it was a container on one of my home servers. The obvious problem was that it needs my Amazon login sitting on that server, and Amazon doesn't do low-privilege versions of "Martin's whole account". My first instinct was IAM Roles Anywhere, until it was pointed out that IAM has no jurisdiction over amazon.co.uk.

What does work: alexapy only needs the password once. Sign in, register as a "device", and Amazon hands back a long-lived refresh token. After that, the token is all you need. So the token could live in Secrets Manager, alexapy could run in a Lambda, and anything that wanted to make an announcement would only need permission to invoke that Lambda.

graph LR
    A[Claude via meta-mcp] -- "lambda.invoke (SSO)" --> B[alexa-announce Lambda]
    B --> C[Secrets Manager<br/>refresh token]
    B --> D[alexa.amazon.co.uk<br/>/api/behaviors/preview]
    D --> E[Kitchen, Echo Show, Bedroom]

No API Gateway, no API keys: invoking a Lambda is already IAM-authenticated.

Spike the scary bit first

The whole plan rested on one assumption I had no way of checking from the armchair: would Amazon accept that refresh token from an AWS data-centre IP address? Amazon is famously twitchy about logins from clouds. If it said no, the design was dead.

So before any spec, any plan, any CDK, Claude wrote two throwaway scripts. The first started alexapy's login proxy on 127.0.0.1:8765; I opened it in my browser and signed in to Amazon as normal, password and 2FA typed into Amazon's own page. The script saved the refresh token, mode 600, and listed sixteen devices.

The second was a throwaway Lambda in the sandbox account, given nothing but that token. It logged in, listed the same sixteen devices, and — with my permission — announced to the kitchen, the Echo Show and the bedroom.

This is a test announcement from Claude.

My entire contribution to the engineering at that point was typing:

I hear you!

The spike also turned up something useful. My first filter for "which devices can speak" was "anything online with a volume control". That list included the Echo Auto in my car, the TV, the Alexa app itself — and a Fire TV called Mom & Dad, which lives at my parents' house. An "announce everywhere" button would have announced at them. Device targets became explicit names, with a configured default.

A cosy living room in another town where an elderly couple look up, startled, from their sofa at a television that has suddenly started talking

Building it properly

With the risky part proven, the rest was a normal piece of work: a design spec, an implementation plan, then subagent-driven development — a fresh agent implements each task, a fresh reviewer checks it, and anything Important goes back for a fix round before the next task starts.

It ended up as:

  • alexa-mcp, a small FastMCP server with two tools: announce (chime, then the text, in sync everywhere) and speak (just the words, no chime).
  • alexa-announce, a Python Lambda holding the alexapy session warm between calls.
  • alexa-mcp-login, a one-off command that runs the browser sign-in and writes the session straight into Secrets Manager, so the token never touches disk.
  • A CDK stack in both sandbox and prod.

The per-task reviews earned their keep. One caught that a network blip would leave a dead session cached in the warm Lambda, so every subsequent call would fail until AWS recycled the container. Another noticed the Lambda bundling claimed a Docker fallback that didn't exist.

And there's a detail I'd never have thought of: the alexapy session's HTTP client is bound to the event loop that created it. The usual asyncio.run() per invocation would close that loop and break the cached session on every warm call. So the handler keeps one loop for the life of the container, and there's a test that proves two invocations see the same one.

The bug no live test would find

Before deploying anything, the whole branch went to a final review on the most capable model, with the alexapy source code alongside. It came back "with fixes", and the most important one was this.

When the Lambda logged in, it called alexapy's login(). With a refresh token present, login() tries the refresh chain first. And if any step of that chain fails — a revoked registration, or just a transient error from Amazon's token endpoint — it quietly resets and falls back to the full credentials flow: fetch Amazon's sign-in page, fill in the form with the email address and an empty password, and submit it.

From an AWS IP address. Every time Claude retried a failed tool call.

That's a fine way to collect CAPTCHAs, security emails and, eventually, a locked Amazon account. It's also completely invisible on the happy path. Every test I could have run against a working token would have passed. You'd only find out the day the token died, from Amazon.

The fix was to stop calling login() and run the refresh chain directly, so a dead token is reported as "session expired, please sign in again" instead of an attempted sign-in:

# Never login.login(): when the refresh chain fails it resets and falls
# back to a credentials sign-in form POST (email, empty password) against
# the user's Amazon account. From AWS, only the refresh token is ever
# used; never a password.
try:
    login._create_session()
    ok = (
        await login.refresh_access_token()
        and await login.exchange_token_for_cookies()
        and await login.get_csrf()
        and await login.test_loggedin(rebuild_session=False)
    )
except Exception as e:
    await _close_quietly(login)
    raise AmazonTransportError(f"login failed: {e}") from e

The same review found that alexapy wraps its own errors in its own exception types, so my carefully mapped network errors never actually matched anything, and that a retry after a send that might have reached Amazon could make the house hear everything twice. Both fixed, both with tests, before anything left sandbox.

Rate exceeded

Sandbox went up, I signed in through the proxy again (each account gets its own registration, so either can be revoked on its own), and the first announcement played from cold in 6.3 seconds. A warm speak took 1.2. I confirmed the important detail:

No chime

Then Claude sent a second announcement straight after the first, and Amazon said:

amazon_error: {'status': 429, 'body': 'Rate exceeded: Too many requests.'}

Which explains why alexapy waits a second and a half between sequences. A 429 means Amazon refused the request and nothing played, so it's safe to retry: the Lambda now waits two seconds and tries once more.

Prod went up, the token from the spike was loaded into the prod secret, and the spike directory, cookies and all, was deleted.

Hello, World!

I restarted meta-mcp and asked Claude to give it a go.

call_mcp("alexa", "announce",
         {"text": "Hello, world! This is Claude, announcing through meta MCP."})

→ Announced on: Martin's Kitchen Echo Dot, Martin's Echo Show, Bedroom Echo

It chimed. In the kitchen, on the Show and in the bedroom, at the same moment, exactly as if I'd asked Alexa myself.

So that's how the sessions call me now. When one is genuinely stuck waiting for me, the house says so, and it says which session it is. I'm sure it's going to be lovely right up until the first time it happens at 2am.


Martin Harris connects cloud infrastructure to the real world. He now has a Lambda in London whose only job is to tell his kitchen that a different computer would like a word.


If you find this blog useful, please consider a donation.