Here's my problem with virtual pets, and honestly with most IoT demos: after the novelty fades, they don't do anything for you. My pet from part 3 has feelings and memory, and I love it, but it contributes nothing to the household.
Time to fix that. In this final part of the series, the pet gets a job: every morning at 7:03, it fetches the AWS "What's New" feed (thanks rss to still be around ❤️), has Amazon Bedrock rewrite the announcements as a briefing in its own excitable little pet voice, has Amazon Polly speak it, and delivers it to the device, where a newspaper badge appears, and tapping it makes the pet read the news to me out loud through the onboard speaker.
It's one of my favorite thing I've built so far this year. My Cloudagotchi is now better informed than I am before my morning hot chocolate (spoiler alert, I don't like cofee... sorry).
In this article, I will walk you through the full pipeline (RSS → Bedrock → Polly → S3 → MQTT → speaker) including the one decision that makes the device side almost embarrassingly simple: asking Polly for the right audio format.
⚠️ Reality check (sorry 😅): the pet's voice is Polly's neural TTS pitched up with SSML. Charming, but it won't fool anyone into thinking a soul lives in the device. Bedrock also occasionally gets too excited about a niche database feature ("HUMAN. WAKE UP. Aurora has a new minor version"). And fair warning: the cloud half of this article worked on the first deploy; the device half taught me four embedded-streaming lessons the hard way. They're all documented below, symptoms included, so your afternoon goes better than mine. Cost, though, is genuinely negligible: one Haiku call and ~1,000 Polly characters a day is under a dollar a month.
Final code: git checkout article-4, or just main, since this completes the project.
The pipeline
One Lambda, one bucket, one schedule. All the intelligence is cloud-side, which means when I want to change the pet's personality, tweak the briefing length, or switch news sources, it's just a cdk deploy, and the firmware never knows anything happened. That's the payoff of the architecture we've been building since part 1.
Step 1 — The news, without an API key
AWS publishes a plain old RSS feed of every announcement. No auth, no scraping, no SDK:
const FEED_URL = 'https://aws.amazon.com/about-aws/whats-new/recent/feed/';
export async function fetchNews(limit = 10) {
const res = await fetch(FEED_URL);
const xml = await res.text();
// Two regexes pull out <item> titles + links. For a feed this
// regular, a full XML parser dependency would be pure ceremony.
...
}
We only keep the titles. That sounds lossy, but it's deliberate: AWS announcement titles are already one-sentence summaries ("Amazon S3 now supports..."), and they're all Bedrock needs to pick the interesting ones. Feeding it full item descriptions doubles your tokens for basically no gain when the output is a 45-second briefing.
Step 2 — Bedrock, in character
This is where the feature goes from "RSS-to-speech" to pet. The system prompt does the heavy lifting:
const PERSONA = `You are Cloudagotchi, a small, excitable AWS virtual pet who reads the morning AWS news to your human. You are affectionate, easily impressed, and sometimes admit you don't fully understand the more complicated services.
Summarize the following AWS announcements as a spoken morning briefing:
- 45 to 60 seconds when read aloud (about 120-150 words)
- Pick only the 3 most interesting items, one short sentence of WHY each matters
- End with one affectionate sign-off sentence
- Plain text only: no emoji, no markdown, no bullets (it will be read aloud)`;
Note the constraints that exist because this is audio: a word-count target expressed in listening time, "no markdown, no bullets" (Polly would read them), and "one sentence of why it matters" so the briefing informs rather than recites. Then one Converse call:
const { output } = await bedrock.send(new ConverseCommand({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', // fast + cheap: perfect here
messages: [{
role: 'user',
content: [{ text: `${PERSONA}\n\nToday's announcements:\n${titles}` }],
}],
inferenceConfig: { maxTokens: 500, temperature: 0.8 },
}));
A real output from my device this week:
"Good morning good morning! I read the news while you slept and I have THREE things! Amazon Bedrock has new smaller models, which means pets like me might get even smarter, imagine! Lambda functions can now... okay I did not fully understand this one but it makes your functions start faster, and fast is good. And S3 got cheaper for cold data, which I think means winter storage? Anyway. I picked these just for you. Have the best day, okay?"
I have received worse briefings from humans...
💡 Why Haiku and not a bigger model? The task is summarize-and-roleplay over ten headlines, well within a small model's comfort zone, it runs 365 times a year, and latency doesn't matter at 7 a.m. Matching model size to task is the closest thing GenAI has to a free lunch. Start small; upgrade only when the output disappoints you.
Step 3 — Polly, and the decision that saves the firmware
Here's the part that trips people up when they put AI-generated audio on a microcontroller. Polly's default output is MP3, and an MP3 decoder on an ESP32 means integrating a decoding library, managing its buffers, and debugging its edge cases. On a hobby timeline, that's a weekend.
Or you change one parameter:
const { AudioStream } = await polly.send(new SynthesizeSpeechCommand({
Text: `<speak><prosody pitch="+20%" rate="105%">${script}</prosody></speak>`,
TextType: 'ssml',
VoiceId: 'Justin',
OutputFormat: 'pcm', // ← the whole trick. Raw samples, no codec.
SampleRate: '16000',
}));
pcm gives us raw 16-bit mono samples, exactly what the board's ES8311 audio codec eats natively. The device-side "decoder" becomes: skip 44 bytes, write the rest to the speaker. We wrap the PCM in a WAV header in the Lambda (44 bytes of 1991 technology, hand-written in briefing.mjs) so the file is also playable in a browser for debugging.
The SSML prosody tag is the pet's larynx: pitch="+20%" turns Polly's "Justin" into something convincingly small and cute. Free squeakiness.
The result is ~1.8 MB per minute, too big for an MQTT message (128 KB limit), which is why S3 enters the picture:
await s3.send(new PutObjectCommand({ Bucket, Key, Body: wav }));
const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket, Key }),
{ expiresIn: 12 * 3600 });
MQTT carries the pointer; HTTPS carries the payload. The presigned URL means the device downloads from a private bucket with zero AWS credentials beyond its IoT certificate, the URL is the authorization, and it expires at lunchtime, because news does too. A lifecycle rule deletes the files after a week.
Step 4 — The paper delivery
The Lambda's last act is ringing every pet's doorbell:
for (const thing of things) { // iot.ListThings
if (!thing.thingName.startsWith('cloudagotchi')) continue;
await iotData.send(new PublishCommand({
topic: `cloudagotchi/${thing.thingName}/briefing`,
payload: JSON.stringify({ url, headline }),
}));
}
And EventBridge Scheduler (same service that makes the pet hungry) makes it a morning ritual. Note the timezone-aware cron, a Scheduler feature that classic EventBridge rules don't have:
new scheduler.CfnSchedule(this, 'MorningSchedule', {
scheduleExpression: 'cron(3 7 * * ? *)',
scheduleExpressionTimezone: 'Europe/Paris', // the pet lives where I live
...
});
Step 5 — The device: badge, tap, play
On the firmware side, the briefing handler stores the URL and shows an orange banner with the headline, built from the same LVGL vocabulary as part 2 (and it wakes the pet if it was dozing; the paperboy rings). The interesting bit is what happens on tap:
static void on_briefing_tapped(void)
{
// Audio streaming blocks for ~1 minute: never do that in an
// LVGL callback, or the whole UI freezes mid-squish.
// Low priority, pinned to core 1: the download must never
// starve the Wi-Fi/TCP stack on core 0.
xTaskCreatePinnedToCore(play_briefing_task, "briefing", 8192, NULL, 2, NULL, 1);
}
...and the entire audio "stack", which streams the WAV chunk-by-chunk so it never needs to fit in RAM:
while (true) {
int n = esp_http_client_read(http, buf, sizeof(buf)); // 16 KB at a time
if (n <= 0) break;
// skip the 44-byte WAV header once, then:
esp_codec_dev_write(s_speaker, buf + offset, n - offset); // → speaker
}
That's it. That's the player. HTTP in, codec out, no decoder in between, the dividend of choosing pcm back in step 3. One design decision in a Lambda function deleted an entire firmware subsystem.
The morning after
Deploy, flash, go to bed:
cd backend && npx cdk deploy CloudagotchiNewsStack
cd ../firmware && idf.py flash
At 7:03, the Lambda wakes, reads the feed, writes the script, records the voice, parks the file, rings the doorbell. On the desk, an orange banner slides across the pet's feet: "News! Amazon Bedrock announces..., tap to listen." pet does its proud little squish.
Tap. A tiny, pitched-up voice fills the room with genuine enthusiasm about storage-class pricing.
Four articles ago this was a dev board in shrink wrap. Now it's a creature with a body (LVGL), reflexes (touch + IMU), a memory (DynamoDB), a metabolism (EventBridge), and as of this morning, a job (Bedrock + Polly). Every layer still visible in the code, every layer replaceable without touching the others.
Why this matters
Beyond the whimsy, this article's pipeline (schedule → fetch → LLM transform → TTS → object storage → presigned pointer → thin device) is a genuinely reusable shape. Swap the RSS feed for your CI status, your support queue, or your kid's school newsletter, and the persona prompt for whatever voice should deliver it, and you've got an ambient audio briefing device for anything. The device firmware never changes. That's the quiet lesson of the whole series: put the personality where you can redeploy it.
And if you build one, please, show it to me!
Try it yourself
-
The Cloudagotchi repo : the finished project is
main - Amazon Bedrock Converse API : one API, many models
-
Amazon Polly SSML reference :
prosodyis just the beginning - AWS "What's New" RSS : the pet's news source
- ESP-IDF HTTP client : the streaming download


Top comments (0)