Play Music on Alexa with Claude Code
Build a private Alexa skill that streams your MP3s from R2 with Lambda.
Introduction
30 Second Summary
Your own music can feel oddly unreachable when your phone is across the room. A spoken request should be enough to start a song you already own.
In this project, you will turn three owned MP3 files into a private voice-controlled jukebox for Alexa. AWS Lambda will create expiring playback links to a private Cloudflare R2 library.
What You'll Build
A voice request to your Amazon Echo will start the matching track through temporary access to your private library.
By the end of this project, you'll have:
- A searchable music catalog that shows the title and artist from each of your three tracks.
- A private library that denies unsigned browser access. A short-lived signed link unlocks one track for playback.
- A working Echo demonstration that starts an owned song from a title, artist, or shuffle request.
- Secret Mission: Use AudioPlayer.PlaybackNearlyFinished to queue another random track without making the bucket public.
Are there any prerequisites?
You need a Mac, three owned MP3 files with title and artist tags, an Amazon Echo, and the Alexa app.
You also need a paid Claude Pro plan plus a payment method if AWS or Cloudflare requests one during account setup.
Before We Start
This checkpoint confirms that your Amazon Echo and Alexa app are available for the demonstration. It also commits you to exactly three owned, correctly tagged MP3 files protected by a private bucket with expiring playback links.
Set Up Your Mac and Accounts
Your three MP3 files and privacy goal are ready. The jukebox now needs the accounts and local tools that support every later step.
This step removes every setup blocker before Claude Code creates or runs the project. You will finish with an installed project inside a dedicated folder on your Mac.
In this step, get ready to:
- Create the four accounts required for development and deployment.
- Install Node.js and Claude Code on your Mac.
- Create the project files and install their dependencies.
Create the required accounts
Your jukebox spans Cloudflare R2, AWS, and Alexa. Each account controls a different part of the private playback path.
Know what can cost money
Claude Code requires an eligible paid Claude account. Claude Pro costs $20 when billed monthly.
Cloudflare states that R2 must be purchased before you can generate an API token. Payment verification can therefore appear while you enable R2.
The three-song workload is expected to remain within the documented R2 and Lambda monthly free allowances. Your paid Claude subscription continues until you cancel it.
- Create or upgrade your Claude account through the official Claude pricing page.
- Create your Cloudflare account through the official Cloudflare registration page.
- Enable R2 from your Cloudflare dashboard.
- Create your AWS account through the official AWS registration page.
- Register a free Amazon developer account through the official Amazon developer registration page.
- Use the same Amazon login connected to your Alexa app for the developer account.
You should now be able to access Claude Code through your paid Claude account. You should also see R2 in Cloudflare plus active AWS and Amazon developer accounts.
Install Node.js and Claude Code
Node.js runs the project scripts. Its installer also provides npm, which downloads the pinned project packages.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check whether Node.js is already installed by running this command:
node --version
What does this command check?
The command asks the active Node.js installation to print its version. Your project requires v24.22.2.
✔️ I see version v24.22.2
Your Node.js runtime matches the project engine exactly. The included npm installation is ready for its own check.
ⓧ I see an older version
The project pins Node.js v24.22.2. Install that release before continuing.
- Download node-v24.22.2.pkg from the official Node.js release directory.
- Open the downloaded package from your Downloads folder.
- Complete the macOS installation wizard.
- Return to Terminal after the installation finishes.
- Confirm the installed version by running the check again:
node --version
What should this show?
The refreshed Terminal session should print v24.22.2. That confirms the required runtime is active.
ⓧ Command not found
Node.js is not installed yet. The official macOS package installs Node.js plus npm together.
- Download node-v24.22.2.pkg from the official Node.js release directory.
- Open the downloaded package from your Downloads folder.
- Complete the macOS installation wizard.
- Return to Terminal after the installation finishes.
- Confirm the installation by running the version check:
node --version
What should this show?
Terminal should now print v24.22.2. The package also installs npm for the next check.
- Confirm that npm was installed with Node.js by running this command:
npm --version
What does this command check?
The command prints the installed npm version. Any version number confirms that the package manager is available.
- Check whether Claude Code is already installed by running this command:
claude --version
What does this command check?
The command prints the installed Claude Code version. Version 2.1.318 is the verified release for this project.
✔️ I see version 2.1.318 or higher
Claude Code is installed. A newer auto-updated release is also suitable for this project.
ⓧ I see an older version
Install the current native release so Claude Code can update through its supported installation path.
- Install the native macOS release by running the official command:
curl -fsSL https://claude.ai/install.sh | bash
What does this command do?
The command downloads Anthropic's official installer over HTTPS. The installer sets up the native Claude Code command on your Mac.
ⓧ Command not found
Claude Code is not installed yet. The native installer adds the command your project uses.
- Install Claude Code by running the official native macOS command:
curl -fsSL https://claude.ai/install.sh | bash
What does this command do?
The command downloads Anthropic's official installer over HTTPS. The installer adds the native Claude Code command to your Mac.
Still missing a command?
Close Terminal after the installer finishes. Reopen it before repeating the relevant version check.
If the command is still unavailable, help me diagnose my Node.js or Claude Code installation.
Create and install the project
The private-alexa-jukebox folder keeps the uploader, security check, voice model, and Lambda function together. Claude Code creates each file from the supplied Complete Code.
- Create the project folder on your Desktop by running these commands:
cd ~/Desktop
mkdir private-alexa-jukebox
cd private-alexa-jukebox
pwd
What do these commands do?
- The first command moves Terminal to your Desktop.
- The second command creates the private-alexa-jukebox folder.
- The third command moves Terminal into that folder.
- The final command prints the current location so you can confirm the folder path.
You should see a path ending in /Desktop/private-alexa-jukebox.
Could not create the folder?
A folder with the same name may already exist on your Desktop. Remove that empty duplicate through Finder before repeating the commands.
For a different folder problem, help me create the project folder safely.
The first Claude Code launch can open a browser for authentication. It can also ask for permission before writing files or running installation commands.
- Start Claude Code inside private-alexa-jukebox by running this command:
claude
What does this command do?
The command starts Claude Code with the current folder as its working context. File changes therefore stay inside private-alexa-jukebox.
- Complete the browser authentication flow if Claude Code asks you to sign in.
- Return to the Claude Code session after authentication succeeds.
- Ask Claude Code to create package.json, .gitignore, config.local.example.json, src/build-library.mjs, src/security-check.mjs, interactionModel.json, lambda/package.json, and lambda/index.mjs exactly from the supplied Complete Code.
- Approve Claude Code's file-writing request if it asks for permission.
Claude Code should report that the project files now exist. The file set includes the local uploader plus the Lambda implementation used later.
- Ask Claude Code to duplicate config.local.example.json as config.local.json without changing its placeholder values.
The copied file remains excluded by .gitignore. Your R2 credentials can therefore stay in a local configuration file during the next step.
- Ask Claude Code to install the main project dependencies by running this command:
npm install
What does this command install?
npm reads package.json and installs the exact AWS SDK plus music-metadata versions defined there. These packages support R2 uploads, signed URLs, and MP3 tag extraction.
The installation should finish with a dependency summary. The new node_modules folder remains excluded by .gitignore.
Did the main installation fail?
Confirm that Claude Code is working inside private-alexa-jukebox. Confirm that package.json exists before trying the installation again.
If npm still stops, help me diagnose the dependency installation.
- Ask Claude Code to install the Lambda dependencies by running this command:
npm run install-lambda
What does this command install?
The script installs the dependencies from lambda/package.json inside the lambda folder. Packaging them beside lambda/index.mjs gives Lambda the pinned SDK versions used by the function.
The installation should finish with another dependency summary. The resulting lambda/node_modules folder remains excluded by .gitignore.
Did the Lambda installation fail?
Confirm that lambda/package.json exists inside the project folder. A missing file prevents the installation script from finding the Lambda package.
For another npm failure, help me diagnose the Lambda dependency installation.
- Compare Claude Code's files with the exact end-state references below.
✔️ Awesome, I've got everything!
Great. Keep the project open in Claude Code for the next step.
ⓧ I'd like to double check the full code
The references below show the exact file contents required at the end of this step.
The complete package.json file is:
{
"name": "private-alexa-jukebox",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": "24.22.2"
},
"scripts": {
"build-library": "node src/build-library.mjs",
"security-check": "node src/security-check.mjs",
"install-lambda": "npm install --prefix lambda",
"package-lambda": "cd lambda && zip -r ../jukebox-lambda.zip ."
},
"dependencies": {
"@aws-sdk/client-s3": "3.1162.0",
"@aws-sdk/s3-request-presigner": "3.1162.0",
"music-metadata": "12.3.4"
}
}
What does this file control?
This manifest pins the Node.js engine and project dependencies. It also defines the commands used to build the library, test private access, install Lambda packages, and create the deployment archive.
The complete .gitignore file is:
node_modules/
lambda/node_modules/
config.local.json
catalog.preview.json
jukebox-lambda.zip
.DS_Store
What does this file protect?
This file excludes installed packages, generated artifacts, and the local credential configuration from version control. The config.local.json entry is the important protection for the uploader credentials.
The complete config.local.example.json file is:
{
"accountId": "your-cloudflare-account-id-here",
"bucket": "alexa-private-jukebox",
"uploadAccessKeyId": "your-r2-write-access-key-id-here",
"uploadSecretAccessKey": "your-r2-write-secret-access-key-here"
}
What does this file provide?
This example defines the four configuration fields required by the uploader. Its values remain safe placeholders that document the expected shape.
The complete copied config.local.json file is:
{
"accountId": "your-cloudflare-account-id-here",
"bucket": "alexa-private-jukebox",
"uploadAccessKeyId": "your-r2-write-access-key-id-here",
"uploadSecretAccessKey": "your-r2-write-secret-access-key-here"
}
Why keep this copy local?
The next step replaces these placeholders with your Cloudflare account and uploader token details. The matching .gitignore entry keeps those future credentials out of version control.
The complete src/build-library.mjs file is:
import { extname, join } from "node:path";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { parseFile } from "music-metadata";
const configUrl = new URL("../config.local.json", import.meta.url);
const previewUrl = new URL("../catalog.preview.json", import.meta.url);
const musicFolder = process.argv[2];
if (!musicFolder) {
throw new Error('Usage: npm run build-library -- "/path/to/your/mp3-folder"');
}
const config = JSON.parse(await readFile(configUrl, "utf8"));
const requiredKeys = [
"accountId",
"bucket",
"uploadAccessKeyId",
"uploadSecretAccessKey"
];
for (const key of requiredKeys) {
if (!config[key] || String(config[key]).startsWith("your-")) {
throw new Error(`Set ${key} in config.local.json before uploading.`);
}
}
const r2 = new S3Client({
region: "auto",
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.uploadAccessKeyId,
secretAccessKey: config.uploadSecretAccessKey
}
});
async function findMp3Files(folder) {
const results = [];
const entries = await readdir(folder, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(folder, entry.name);
if (entry.isDirectory()) {
results.push(...await findMp3Files(fullPath));
} else if (entry.isFile() && extname(entry.name).toLowerCase() === ".mp3") {
results.push(fullPath);
}
}
return results.sort((a, b) => a.localeCompare(b));
}
function objectUrl(accountId, bucket, key) {
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
return `https://${bucket}.${accountId}.r2.cloudflarestorage.com/${encodedKey}`;
}
const files = await findMp3Files(musicFolder);
if (files.length !== 3) {
throw new Error(`Expected exactly 3 MP3 files, but found ${files.length}.`);
}
const tracks = [];
for (const [index, filePath] of files.entries()) {
const metadata = await parseFile(filePath, { skipCovers: true });
const title = metadata.common.title?.trim();
const artist = metadata.common.artist?.trim();
if (!title || !artist) {
throw new Error(`Missing title or artist tag: ${filePath}`);
}
const id = `track-${String(index + 1).padStart(3, "0")}`;
const key = `tracks/${id}.mp3`;
await r2.send(new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: await readFile(filePath),
ContentType: "audio/mpeg"
}));
tracks.push({ id, title, artist, key });
console.log(`Uploaded ${title} by ${artist} as ${key}`);
}
const catalog = {
generatedAt: new Date().toISOString(),
tracks
};
const catalogText = JSON.stringify(catalog, null, 2);
await writeFile(previewUrl, `${catalogText}\n`, "utf8");
await r2.send(new PutObjectCommand({
Bucket: config.bucket,
Key: "catalog.json",
Body: catalogText,
ContentType: "application/json"
}));
console.log(`Uploaded catalog.json with ${tracks.length} tracks.`);
console.log(`Wrote local preview to ${previewUrl.pathname}.`);
console.log("Unsigned URL to test:");
console.log(objectUrl(config.accountId, config.bucket, tracks[0].key));
What does this script prepare?
This script finds exactly three MP3 files and reads their title plus artist tags. It uploads the tracks and catalog before printing an unsigned object URL for the privacy test.
The complete src/security-check.mjs file is:
import { readFile } from "node:fs/promises";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const config = JSON.parse(
await readFile(new URL("../config.local.json", import.meta.url), "utf8")
);
const catalog = JSON.parse(
await readFile(new URL("../catalog.preview.json", import.meta.url), "utf8")
);
const track = catalog.tracks[0];
const r2 = new S3Client({
region: "auto",
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.uploadAccessKeyId,
secretAccessKey: config.uploadSecretAccessKey
}
});
function objectUrl(accountId, bucket, key) {
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
return `https://${bucket}.${accountId}.r2.cloudflarestorage.com/${encodedKey}`;
}
async function statusOnly(url) {
const response = await fetch(url);
return response.status;
}
const unsignedStatus = await statusOnly(
objectUrl(config.accountId, config.bucket, track.key)
);
console.log(`Unsigned request: ${unsignedStatus}`);
if (![401, 403].includes(unsignedStatus)) {
throw new Error("Unsigned access was not denied as expected.");
}
const signedUrl = await getSignedUrl(
r2,
new GetObjectCommand({ Bucket: config.bucket, Key: track.key }),
{ expiresIn: 1 }
);
const freshStatus = await statusOnly(signedUrl);
console.log(`Fresh signed request: ${freshStatus}`);
if (freshStatus !== 200) {
throw new Error("The fresh signed URL did not return 200.");
}
await new Promise((resolve) => setTimeout(resolve, 2000));
const expiredStatus = await statusOnly(signedUrl);
console.log(`Expired signed request: ${expiredStatus}`);
if (expiredStatus !== 403) {
throw new Error("The expired signed URL did not return 403.");
}
console.log("Security check passed.");
What does this script prove?
This script compares anonymous access with a one-second signed link. It confirms that a fresh signed request succeeds before the same link expires.
The complete interactionModel.json file is:
{
"interactionModel": {
"languageModel": {
"invocationName": "private jukebox",
"intents": [
{
"name": "PlaySongIntent",
"slots": [
{
"name": "SongQuery",
"type": "AMAZON.SearchQuery"
}
],
"samples": [
"play {SongQuery}",
"start {SongQuery}",
"listen to {SongQuery}"
]
},
{
"name": "PlayArtistIntent",
"slots": [
{
"name": "ArtistQuery",
"type": "AMAZON.SearchQuery"
}
],
"samples": [
"play music by {ArtistQuery}",
"play songs by {ArtistQuery}",
"put on music by {ArtistQuery}"
]
},
{
"name": "ShuffleMusicIntent",
"slots": [],
"samples": [
"shuffle my music",
"shuffle the library",
"play something random"
]
},
{
"name": "AMAZON.CancelIntent",
"samples": []
},
{
"name": "AMAZON.HelpIntent",
"samples": []
},
{
"name": "AMAZON.StopIntent",
"samples": []
},
{
"name": "AMAZON.PauseIntent",
"samples": []
},
{
"name": "AMAZON.ResumeIntent",
"samples": []
},
{
"name": "AMAZON.NextIntent",
"samples": []
},
{
"name": "AMAZON.PreviousIntent",
"samples": []
},
{
"name": "AMAZON.RepeatIntent",
"samples": []
},
{
"name": "AMAZON.ShuffleOnIntent",
"samples": []
},
{
"name": "AMAZON.ShuffleOffIntent",
"samples": []
},
{
"name": "AMAZON.LoopOnIntent",
"samples": []
},
{
"name": "AMAZON.LoopOffIntent",
"samples": []
},
{
"name": "AMAZON.StartOverIntent",
"samples": []
},
{
"name": "AMAZON.FallbackIntent",
"samples": []
}
],
"types": []
}
}
}
What does this model recognize?
This interaction model defines the private jukebox invocation name. It also defines title, artist, and shuffle requests alongside the supplied built-in audio controls.
The complete lambda/package.json file is:
{
"name": "private-alexa-jukebox-lambda",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "3.1162.0",
"@aws-sdk/s3-request-presigner": "3.1162.0"
}
}
What does this package contain?
This Lambda-specific manifest packages the pinned S3 client and presigner beside the function. The deployment therefore controls the SDK versions it uses.
The complete lambda/index.mjs file is:
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const requiredEnvironment = [
"R2_ACCOUNT_ID",
"R2_ACCESS_KEY_ID",
"R2_SECRET_ACCESS_KEY",
"R2_BUCKET"
];
for (const key of requiredEnvironment) {
if (!process.env[key]) {
throw new Error(`Missing Lambda environment variable: ${key}`);
}
}
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY
}
});
const bucket = process.env.R2_BUCKET;
function normalize(value = "") {
return value
.toLocaleLowerCase("en-US")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
function getSlot(event, name) {
return event.request?.intent?.slots?.[name]?.value?.trim() ?? "";
}
function emptyResponse() {
return { version: "1.0", response: {} };
}
function speechResponse(text, shouldEndSession = false) {
return {
version: "1.0",
response: {
outputSpeech: { type: "PlainText", text },
reprompt: shouldEndSession
? undefined
: { outputSpeech: { type: "PlainText", text } },
shouldEndSession
}
};
}
async function signedObjectUrl(key, expiresIn = 3600) {
return getSignedUrl(
r2,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn }
);
}
async function loadCatalog() {
const url = await signedObjectUrl("catalog.json", 60);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Catalog request failed with HTTP ${response.status}.`);
}
return response.json();
}
function randomTrack(tracks) {
return tracks[Math.floor(Math.random() * tracks.length)];
}
function findSong(tracks, spokenTitle) {
const query = normalize(spokenTitle);
return tracks.find((track) => normalize(track.title) === query)
?? tracks.find((track) => {
const title = normalize(track.title);
return title.includes(query) || query.includes(title);
});
}
function findArtistTrack(tracks, spokenArtist) {
const query = normalize(spokenArtist);
const matches = tracks.filter((track) => {
const artist = normalize(track.artist);
return artist === query || artist.includes(query) || query.includes(artist);
});
return matches.length ? randomTrack(matches) : undefined;
}
async function playTrack(track, offsetInMilliseconds = 0) {
const url = await signedObjectUrl(track.key, 3600);
return {
version: "1.0",
response: {
outputSpeech: {
type: "PlainText",
text: `Playing ${track.title} by ${track.artist}.`
},
directives: [
{
type: "AudioPlayer.Play",
playBehavior: "REPLACE_ALL",
audioItem: {
stream: {
url,
token: track.id,
offsetInMilliseconds
}
}
}
],
shouldEndSession: true
}
};
}
function stopResponse() {
return {
version: "1.0",
response: {
outputSpeech: { type: "PlainText", text: "Stopping the jukebox." },
directives: [{ type: "AudioPlayer.Stop" }],
shouldEndSession: true
}
};
}
export async function handler(event) {
const requestType = event.request?.type ?? "Unknown";
const intentName = event.request?.intent?.name ?? "None";
console.log(JSON.stringify({
requestType,
intentName,
token: event.request?.token ?? event.context?.AudioPlayer?.token ?? null,
offset: event.request?.offsetInMilliseconds
?? event.context?.AudioPlayer?.offsetInMilliseconds
?? null,
error: event.request?.error?.type ?? null
}));
try {
if (requestType.startsWith("AudioPlayer.")) {
return emptyResponse();
}
if (requestType === "SessionEndedRequest"
|| requestType === "System.ExceptionEncountered") {
return emptyResponse();
}
if (requestType === "LaunchRequest") {
return speechResponse(
"Private Jukebox is ready. Ask me to play a song, play music by an artist, or shuffle my music."
);
}
if (requestType !== "IntentRequest") {
return speechResponse("I did not recognize that request.", true);
}
if (["AMAZON.StopIntent", "AMAZON.CancelIntent", "AMAZON.PauseIntent"]
.includes(intentName)) {
return stopResponse();
}
if (intentName === "AMAZON.HelpIntent") {
return speechResponse(
"Try saying play followed by a song title, play music by an artist, or shuffle my music."
);
}
const catalog = await loadCatalog();
const tracks = catalog.tracks ?? [];
if (!tracks.length) {
return speechResponse("Your private music catalog is empty.", true);
}
if (intentName === "PlaySongIntent") {
const spokenTitle = getSlot(event, "SongQuery");
const track = findSong(tracks, spokenTitle);
return track
? playTrack(track)
: speechResponse(`I could not find ${spokenTitle} in your library.`, true);
}
if (intentName === "PlayArtistIntent") {
const spokenArtist = getSlot(event, "ArtistQuery");
const track = findArtistTrack(tracks, spokenArtist);
return track
? playTrack(track)
: speechResponse(`I could not find music by ${spokenArtist}.`, true);
}
if (intentName === "ShuffleMusicIntent" || intentName === "AMAZON.NextIntent") {
return playTrack(randomTrack(tracks));
}
if (intentName === "AMAZON.ResumeIntent") {
const token = event.context?.AudioPlayer?.token;
const offset = event.context?.AudioPlayer?.offsetInMilliseconds ?? 0;
const track = tracks.find((item) => item.id === token);
return track
? playTrack(track, offset)
: speechResponse("Ask me to play a song before using resume.", true);
}
return speechResponse(
"This version supports play by title, play by artist, shuffle, next, pause, and resume."
);
} catch (error) {
console.error(error);
return speechResponse(
"The jukebox could not reach the private library. Check the Lambda logs.",
true
);
}
}
What does this function provide?
This Lambda function validates the four R2 environment variables and signs private catalog plus track requests. It supports title, artist, shuffle, next, resume, stop, cancel, pause, help, and generic AudioPlayer events.
Successful playback returns an AudioPlayer.Play directive with REPLACE_ALL behavior. Each track ID becomes the playback token.
Before the final check, what versions do you expect Terminal to report for Node.js and Claude Code?
- Ask Claude Code to run all three version checks in its shell:
node --version
npm --version
claude --version
What does this final check prove?
The three commands confirm that the runtime, package manager, and coding assistant are available from the project environment. They catch an incomplete installation before the build starts.
You should see v24.22.2, an npm version number, and 2.1.318 (Claude Code) or a newer auto-updated Claude Code version.
- Ask Claude Code to report the project file inventory plus the installed main and Lambda dependency folders.
The report should include all nine project files shown in the reference tab. It should also confirm that the main and Lambda dependencies are installed.
That is the setup work complete. Your Mac now has the accounts, runtime, authenticated assistant, project files, and dependencies needed to turn your tagged MP3s into a private cloud library.
Build Your Private R2 Library
Your local tools are ready. Your three tagged MP3 files still live only on your Mac.
In this step, you will turn those files into a searchable catalog inside Cloudflare R2. You will also test whether an unsigned browser request can cross the library's private access boundary.
In this step, get ready to:
- Create a private R2 Standard bucket for your music library.
- Separate uploader access from the read-only access reserved for playback.
- Turn three tagged MP3 files into a cataloged private library.
Create the private R2 bucket and credentials
A bucket groups files as objects inside object storage. This bucket keeps your three tracks together without exposing a public playback address.
- Return to the Cloudflare dashboard from your account setup.
- Select R2 in the dashboard navigation.
- Start creating a bucket from the R2 overview.
- Enter alexa-private-jukebox as the bucket name.
- Choose Standard as the storage class.
- Finish creating the bucket.
That is a meaningful security win. Your music bucket now exists without public access enabled.
- Open Settings for alexa-private-jukebox.
- Confirm that Public Development URL is disabled.
- Confirm that Custom Domains has no connected domain.
Credential setup can feel high stakes because a secret grants bucket access. The write secret stays only in your local Git-ignored configuration.
- Return to the R2 API token area in your Cloudflare account.
- Create a bucket-scoped token for alexa-private-jukebox with Object Read & Write permission.
- Save its Access Key ID in your secure credential manager.
- Save its Secret Access Key in the same secure entry.
- Create a second bucket-scoped token for alexa-private-jukebox with Object Read only permission.
- Save the second token's Access Key ID in a separate secure entry reserved for Lambda.
- Save the second token's Secret Access Key in that separate secure entry.
Why use two tokens?
The local uploader needs Object Read & Write because it creates your track objects plus the catalog. AWS Lambda only needs Object Read only during playback.
This follows least privilege. A playback credential cannot replace your tracks or catalog.
- Return to Settings for the alexa-private-jukebox bucket.
Configure the local uploader
The existing build script reads account details from config.local.json. The read-only playback token stays out of this file.
Which value goes where?
- The accountId field holds your Cloudflare R2 account ID.
- The bucket field stays set to alexa-private-jukebox.
- The uploadAccessKeyId field holds the write token's Access Key ID.
- The uploadSecretAccessKey field holds the write token's Secret Access Key.
- Switch back to the private-alexa-jukebox workspace in Claude Code.
- Open config.local.json from that workspace.
- Update config.local.json with the four mapped values.
- Save config.local.json.
Keep the write secret local
The supplied .gitignore already excludes config.local.json. The config.local.example.json file remains a placeholder-only template.
Upload and inspect the private library
The build script reads each file's MP3 metadata to get its title plus artist. It generates stable track IDs before uploading the audio objects and catalog.
Before you run the build, predict which catalog values will come from your MP3 tags. Also consider which values the script will generate.
- Build the library with your absolute MP3 folder path by adapting this command:
npm run build-library -- "/path/to/your/mp3-folder"
What does this command do?
- The build script searches the selected folder for exactly three .mp3 files.
- The music-metadata package reads the title plus artist tags from each file.
- The script uploads the tracks with IDs from track-001 through track-003.
- The script writes catalog.preview.json locally before uploading catalog.json.
- The final terminal lines include an unsigned URL for the first uploaded track.
- Confirm that the terminal prints three lines beginning with Uploaded.
- Confirm that the terminal prints Uploaded catalog.json with 3 tracks..
- Confirm that the terminal prints a line beginning with Wrote local preview to.
- Confirm that the terminal prints Unsigned URL to test: above an object URL.
Library upload not completing?
- A file-count problem means the selected folder does not contain exactly three .mp3 files. Point the command at the folder containing only your three selected tracks.
- A metadata problem means at least one file lacks a title or artist tag. Add the missing tag before running the build again.
- For an upload problem, compare the account ID plus bucket name with Cloudflare. Confirm that the uploader token is scoped to alexa-private-jukebox.
Help me troubleshoot this R2 library upload.
- Return to the private-alexa-jukebox workspace after the command finishes.
- Open the generated catalog.preview.json file.
- Confirm that generatedAt contains a timestamp.
- Confirm that the catalog contains exactly three track entries.
- Confirm that the IDs are track-001, track-002, and track-003.
- Confirm that each title plus artist matches the corresponding MP3 tags.
- Confirm that the three keys end with tracks/track-001.mp3, tracks/track-002.mp3, and tracks/track-003.mp3.
Your tags now drive a visible three-track catalog. The same catalog also exists as catalog.json inside the private bucket.
The printed object URL points directly to your first uploaded track. The browser request carries no R2 credentials.
Before you send that request, predict whether the browser can retrieve the audio from your current bucket configuration.
- Copy the object URL printed below Unsigned URL to test:.
- Switch back to the browser you used for the Cloudflare dashboard.
- Open a new browser tab.
- Paste the unsigned object URL into the address bar.
- Press Enter.
You will see either 401 Unauthorized or 403 AccessDenied instead of audio. That denial proves the object has no public access path.
Your three tracks now form a private searchable catalog. Next, you will test a temporary link that opens one object for a limited time.
Verify Expiring Access
Your three tagged tracks now sit in a private Cloudflare R2 bucket. The rejected unsigned request proved that anonymous listeners cannot reach them.
Alexa still needs a temporary route to one chosen track. Claude Code will help you verify that a presigned URL opens that route only for its configured lifetime.
In this step, get ready to:
- Trace how the security check creates temporary access without exposing the R2 secret.
- Compare unsigned access with fresh signed access and expired signed access.
- Reconfirm that the R2 bucket has no public route.
Review the temporary access boundary
A presigned URL is a bearer token that authorizes one named operation on one object until its expiry. The write credentials remain in config.local.json on your trusted Mac.
- Return to the Claude Code session that already has private-alexa-jukebox open.
- Ask Claude Code to trace how src/security-check.mjs loads the local R2 credentials.
- Ask Claude Code to explain how GetObjectCommand limits the signed request to one object.
- Ask Claude Code to identify the expiry configured by { expiresIn: 1 }.
What should Claude point out?
- The script reads the uploader credentials from config.local.json so it can sign the request on your Mac.
- GetObjectCommand identifies one bucket object for the signed operation.
- getSignedUrl() creates the temporary URL without revealing the R2 secret access key.
- { expiresIn: 1 } limits the test URL to one second.
Compare anonymous and temporary access
The script compares three requests to the same object. Their HTTP statuses reveal where access begins and ends.
Before you run the check, which request do you expect to succeed during its access window?
- Run the access check from the private-alexa-jukebox folder by using this command:
npm run security-check
What does this command check?
- Unsigned request: reports 401 Unauthorized or 403 AccessDenied. Either result confirms that anonymous access is denied.
- Fresh signed request: reports 200. This confirms that the one-second signed URL works while valid.
- Expired signed request: reports 403 ExpiredRequest. This confirms that the same URL stops working after expiry.
- Security check passed. confirms that all three access checks matched their required states.
That is the temporary access boundary working. Your private object opens through a valid signed URL before closing again after expiry.
Fresh signed request missing 200?
- Confirm that the first key in catalog.preview.json matches tracks/track-001.mp3 in the bucket.
- Confirm that config.local.json still names the alexa-private-jukebox bucket.
- Check that your Mac system clock is accurate and that the uploader token still has bucket-scoped Object Read & Write access.
Help me diagnose the security check results.
Confirm the bucket stays private
A valid signed link remains safe only while public access stays disabled. Your AWS Lambda code uses longer expiry windows for catalog access and track playback.
Before you check the bucket, do you expect to find any public route that bypasses the signed URL?
- Return to the Cloudflare R2 dashboard from earlier.
- Select the alexa-private-jukebox bucket.
- Select Settings.
- Confirm that Public Development URL is disabled.
- Confirm that no Custom Domain is connected.
You will see the Public Development URL disabled. You will also see no connected custom domain.
What does this prove?
The bucket has no public delivery route. A caller needs valid R2 credentials or an unexpired signed URL to fetch an object.
- Return to the Claude Code session from earlier.
- In src/security-check.mjs, locate { expiresIn: 1 }.
- In lambda/index.mjs, locate const url = await signedObjectUrl("catalog.json", 60);.
- In lambda/index.mjs, locate const url = await signedObjectUrl(track.key, 3600);.
The security check uses a one-second window so you can observe expiration immediately. The Lambda signs catalog.json for 60 seconds.
The Lambda signs each playback track for 3600 seconds. That gives the Echo one hour to fetch the stream without making the bucket public.
Your private objects now have a proven temporary access path. Next, you will teach Alexa how to recognize requests for a title, an artist, or shuffle.
Build the Alexa Voice Model
Your private library now delivers temporary access without exposing the bucket. That gives the playback service a safe path to each track.
Alexa still needs a vocabulary for choosing a title, an artist, or a random song. In this step, you will create the interaction model that maps those phrases to the right intents.
In this step, get ready to:
- Create the English (US) Private Jukebox custom skill.
- Configure the interaction model with Audio Player enabled.
- Prove the model recognizes title, artist, or shuffle requests.
Create the custom skill
The model defines the words your skill understands. Each custom intent represents one type of music request.
- Return to the Alexa Developer Console in your browser.
- Click Create Skill.
- Enter Private Jukebox in the skill name field.
- Select English (US) as the primary locale.
- Select Custom under the model options.
- Select Provision your own under the hosting options.
- Click Create skill.
You will land on the skill's build page. The console is ready to receive your voice model.
Why Provision Your Own?
This option lets your own AWS Lambda function answer requests from the skill. It keeps the playback logic connected to the private R2 library you already built.
The voice model stays in the Alexa Developer Console. You will connect the function in the next step.
- Return to the private-alexa-jukebox project from earlier.
- Open the existing interactionModel.json file in your editor.
- Click Edit in the editor menu.
- Select Select All.
- Click Edit again.
- Select Copy.
Your clipboard now contains the complete model from the project.
- Return to the Alexa Developer Console.
- Select Interaction Model under Custom.
- Select JSON Editor.
- Click inside the JSON editor.
- Press Cmd+A (macOS) or Ctrl+A (Windows) to select the starter JSON.
- Press Cmd+V (macOS) or Ctrl+V (Windows) to paste your model.
- Click Save.
You should see the model accepted with its song, artist, shuffle, and built-in playback intents.
✔️ Awesome, I've got everything!
Great. Your saved model now matches the project file.
ⓧ I'd like to double check the full code
Compare the JSON Editor contents with this complete interactionModel.json file.
{
"interactionModel": {
"languageModel": {
"invocationName": "private jukebox",
"intents": [
{
"name": "PlaySongIntent",
"slots": [
{
"name": "SongQuery",
"type": "AMAZON.SearchQuery"
}
],
"samples": [
"play {SongQuery}",
"start {SongQuery}",
"listen to {SongQuery}"
]
},
{
"name": "PlayArtistIntent",
"slots": [
{
"name": "ArtistQuery",
"type": "AMAZON.SearchQuery"
}
],
"samples": [
"play music by {ArtistQuery}",
"play songs by {ArtistQuery}",
"put on music by {ArtistQuery}"
]
},
{
"name": "ShuffleMusicIntent",
"slots": [],
"samples": [
"shuffle my music",
"shuffle the library",
"play something random"
]
},
{
"name": "AMAZON.CancelIntent",
"samples": []
},
{
"name": "AMAZON.HelpIntent",
"samples": []
},
{
"name": "AMAZON.StopIntent",
"samples": []
},
{
"name": "AMAZON.PauseIntent",
"samples": []
},
{
"name": "AMAZON.ResumeIntent",
"samples": []
},
{
"name": "AMAZON.NextIntent",
"samples": []
},
{
"name": "AMAZON.PreviousIntent",
"samples": []
},
{
"name": "AMAZON.RepeatIntent",
"samples": []
},
{
"name": "AMAZON.ShuffleOnIntent",
"samples": []
},
{
"name": "AMAZON.ShuffleOffIntent",
"samples": []
},
{
"name": "AMAZON.LoopOnIntent",
"samples": []
},
{
"name": "AMAZON.LoopOffIntent",
"samples": []
},
{
"name": "AMAZON.StartOverIntent",
"samples": []
},
{
"name": "AMAZON.FallbackIntent",
"samples": []
}
],
"types": []
}
}
}
What Does This Model Define?
- PlaySongIntent uses one SongQuery slot for a spoken title.
- PlayArtistIntent uses one ArtistQuery slot for a spoken artist.
- ShuffleMusicIntent recognizes a shuffle request without a slot.
- The built-in intents cover controls such as pause, resume, next, and stop.
Build with Audio Player enabled
Audio Player gives the skill access to Alexa's long-form playback controls. The model must include this interface before its intents can drive music playback.
- Select Interfaces under Custom in the left navigation.
- Enable Audio Player.
- Choose Save Interfaces.
Before you build the model, which three custom request types do you expect Alexa to recognize?
- Choose Build Model to validate the interaction model.
You should see the model build complete successfully. Alexa has now accepted the invocation name, intents, slots, samples, and Audio Player interface.
Model Build Not Succeeding?
Compare the JSON Editor with the complete file above. Confirm that each AMAZON.SearchQuery slot is the only slot in its intent.
Use Help me diagnose my Alexa interaction model build failure. Include the build feedback without sharing any account credentials.
Test the utterance mappings
Utterance testing shows which intent Alexa chooses before a Lambda function is connected. Using your real catalog values proves the model understands the words you plan to say to your Echo.
- Return to catalog.preview.json in your project from earlier.
- Choose one track from the catalog.
- Record its title here: one track title.
- Record the matching artist here: the matching artist.
- Return to the Alexa Developer Console.
- Select Evaluate Model.
- Select Utterance Profiler.
Before you run the first test, which intent do you expect Alexa to choose for a phrase beginning with play?
- Enter play followed by one track title.
- Run the utterance test.
You should see PlaySongIntent as the mapped intent. The spoken title should appear in SongQuery.
- Replace the test phrase with play music by followed by the matching artist.
- Run the utterance test.
You should see PlayArtistIntent as the mapped intent. The spoken artist should appear in ArtistQuery.
One final mapping remains. Before you test it, which intent should handle a request with no title or artist?
- Replace the test phrase with shuffle my music.
- Run the utterance test.
You should see ShuffleMusicIntent as the mapped intent. That confirms all three voice routes are ready for fulfillment.
Seeing the Wrong Intent?
Use the title and artist exactly as they appear in catalog.preview.json. Confirm that you rebuilt the model after saving the JSON.
Use Help me troubleshoot an incorrect Alexa utterance mapping. Share the tested phrase and mapped intent.
Your skill can now understand song, artist, and shuffle requests. Next up, you will connect those requests to Lambda and hear your private music on the Echo.
Deploy and Play Your Music
Your Alexa voice model already recognizes title, artist, and shuffle requests. It cannot play a track until a trusted endpoint handles each request.
A Node.js function on AWS Lambda becomes that trusted endpoint. It reads your catalog from Cloudflare R2.
For each matching track, the function signs a one-hour object link. Its AudioPlayer.Play response sends that private stream to your Amazon Echo.
In this step, get ready to:
- Create a deployable Lambda function from your existing package.
- Connect the skill through a verified Lambda trigger and endpoint.
- Play your owned music by title, artist, and shuffle request.
Package and deploy the Lambda function
Lambda expects the handler file and its dependencies at the root of the deployment archive. Your existing package script builds that structure from the lambda/ folder.
- Return to the Terminal panel in your private-alexa-jukebox folder.
- Create the Lambda deployment archive by running this command:
npm run package-lambda
What does this command do?
- The package-lambda script switches into the lambda/ folder before creating the archive.
- That location places index.mjs at the archive root.
- The archive also includes the pinned dependencies from node_modules/.
- Confirm that jukebox-lambda.zip now appears inside the private-alexa-jukebox folder.
The archive is ready for Lambda. The terminal output should list files as they are added to jukebox-lambda.zip.
Package missing?
- Confirm that the Terminal prompt points to the private-alexa-jukebox folder.
- Confirm that the lambda/ folder still contains index.mjs, package.json, and node_modules/.
- Help me debug the Lambda packaging command.
- Open AWS Lambda in your browser.
- Create an Event Function named Private Jukebox using the Node.js 24.x runtime.
You should now see the Private Jukebox function page with the selected Node.js runtime.
- Select Code on the function page.
- Select Upload from under Code source.
- Select .zip file.
- Select Upload.
- Choose jukebox-lambda.zip in the file picker.
- Select Open.
- Select Save.
The code source should now show index.mjs, package.json, and node_modules/ at the archive root.
- Set the function handler to index.handler in the runtime configuration.
- Save the runtime configuration.
The function page should now show index.handler as the handler.
Credential setup can feel risky. The read-only token belongs only in Lambda's environment configuration.
Map the playback credentials
- R2_ACCOUNT_ID uses your Cloudflare account ID.
- R2_ACCESS_KEY_ID uses the Access Key ID from the read-only R2 token.
- R2_SECRET_ACCESS_KEY uses the Secret Access Key from the same read-only R2 token.
- R2_BUCKET uses alexa-private-jukebox.
- Select Configuration on the function page.
- Select Environment variables.
- Select Edit.
The environment variable editor is now ready for the playback configuration.
- Add the four mapped entries using Add environment variable.
- Select Save.
The environment variable list should now show R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET.
Why use the read-only token?
The function only needs to read catalog.json and the three MP3 objects. The read-only token prevents the playback service from changing or deleting your library.
Lambda encrypts environment variables at rest. A production version should move authorization tokens into Secrets Manager and rotate them regularly.
Connect the verified Alexa endpoint
The trigger controls which skill can invoke your function. Skill ID verification rejects requests from other skills before Lambda runs.
- Return to the Private Jukebox skill in the Alexa Developer Console.
- Record the skill ID here: your Alexa skill ID.
- Return to the Private Jukebox Lambda function page.
- Add Alexa Skills Kit as a trigger.
- Enable Skill ID verification.
- Enter your Alexa skill ID in the skill ID field.
- Save the trigger.
That connection is now locked to your skill. The Lambda trigger list should show Alexa Skills Kit with Skill ID verification enabled.
Trigger rejected?
- Confirm that the recorded skill ID came from the Private Jukebox skill.
- Confirm that Skill ID verification is enabled on the trigger.
- Help me debug the Alexa Skills Kit trigger.
The function also has an ARN that identifies it as the skill's fulfillment endpoint. You will copy that ARN back into the existing skill.
- Record the Lambda ARN from the function page here: your Lambda ARN.
- Return to the Private Jukebox skill in the Alexa Developer Console.
- Select Custom.
- Select Endpoint.
- Select AWS Lambda ARN.
- Paste your Lambda ARN into Default Region.
- Select Save Endpoints.
- Select Build Model.
Your voice model now has a verified route to the deployed function. The model build should complete successfully with the Lambda ARN saved as its endpoint.
Deployment checkpoint
- The Lambda runtime is Node.js 24.x.
- The handler is index.handler.
- The uploaded package is jukebox-lambda.zip.
- The function uses the read-only R2 token through four environment variables.
- The Alexa Skills Kit trigger has Skill ID verification enabled.
- The Alexa endpoint contains the Lambda ARN.
Test playback on your Echo
Each Echo request now invokes Lambda. The function matches the catalog entry before creating a fresh one-hour R2 link.
The returned directive uses REPLACE_ALL to start the requested track. The track ID becomes the playback token.
- In catalog.preview.json from earlier, record one artist here: one artist from your catalog.
- Say “Alexa, ask private jukebox to play music by one artist from your catalog.”
Alexa should announce a matching title and artist. Your Echo should then start one of that artist's owned MP3s.
- Say “Alexa, ask private jukebox to shuffle my music.”
Alexa should announce a catalog track. Your Echo should then replace the current stream with that track.
- Open the function's CloudWatch log stream.
- Confirm that the newest entries record PlayArtistIntent and ShuffleMusicIntent.
Those log entries prove that both requests reached your deployed handler.
Does a request miss the catalog?
- Check the Alexa app activity to see which words Alexa recognized.
- Compare those words with the title or artist value in catalog.preview.json.
- Help me debug a catalog match or playback failure.
- Record one title from catalog.preview.json here: one song title from your catalog.
- Start a short video recording that includes your Echo.
Before you ask, do you think Alexa will announce the exact catalog match before playback begins?
- Say “Alexa, ask private jukebox to play one song title from your catalog.”
- Stop the recording after the owned MP3 starts playing.
Alexa should announce the matching title and artist. Your Echo should then start the requested MP3 through its one-hour signed link.
You have completed the full private playback path. A spoken request now reaches your verified Lambda function and plays an owned track without making the R2 bucket public.
Secret mission
Make Shuffle Continue Automatically
Your jukebox currently stops after one shuffled track. Extend it so a playback event queues a different private track with a fresh one-hour link before the current song finishes.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep, pause, or delete this jukebox. The three-song cloud workload should remain inside its documented free allowances.
Cost warning
Claude Pro costs $20 per month plus applicable tax when billed monthly. The subscription continues billing until cancellation.
Cloudflare R2 Standard includes 10 GB-month storage, 1 million Class A operations, 10 million Class B operations, and free internet egress each month.
AWS Lambda includes one million requests and 400,000 GB-seconds each month. Other account activity can consume these allowances.
Larger libraries or frequent playback can create charges. Pause or Delete is the safer choice when you have finished testing.
Resources you used:
- The private Cloudflare R2 Standard bucket alexa-private-jukebox with three MP3 objects plus catalog.json.
- The bucket-scoped Object Read & Write uploader token.
- The bucket-scoped Object Read only playback token.
- The AWS Lambda Event Function for Private Jukebox with its verified Alexa Skills Kit trigger.
- The IAM execution role linked to the Lambda function.
- The CloudWatch log group containing the jukebox events.
- The Private Jukebox skill in the Alexa Developer Console.
- The local config.local.json file inside the private-alexa-jukebox folder open in Claude Code.
- The Claude Pro subscription used for Claude Code if you created one for this project.
Keep everything running
Keep the playback path active if you plan to continue using the jukebox. Your Echo can keep requesting private tracks through the deployed skill.
- Keep the alexa-private-jukebox bucket with its four stored objects.
- Keep the Object Read only playback token active for Lambda.
- Keep the Private Jukebox Lambda function active.
- Keep the Private Jukebox Alexa skill enabled for testing.
- Revoke the Object Read & Write uploader token from the R2 API token page until you need to update the library.
- Retain Claude Pro only while you plan to keep using Claude Code.
Limit Write Access
Playback needs read access only. Revoking the uploader token removes an unused write path while your Echo continues playing the existing library.
Pause - I'll come back to this later
Pause access while preserving the cloud resources for a future return. Your stored library remains available for reactivation.
- Disable the Private Jukebox skill for testing in the Alexa Developer Console.
- Revoke the Object Read & Write uploader token from the R2 API token page.
- Revoke the Object Read only playback token from the R2 API token page.
- Leave the alexa-private-jukebox bucket in place.
- Leave the Private Jukebox Lambda function in place.
The four R2 objects remain stored while the project is paused. Their storage continues to count toward your R2 usage.
Delete - I don't want to use this again
Removing a working jukebox can feel drastic. This option clears the playback stack while leaving your three original MP3 files untouched.
- Delete the Private Jukebox skill from the Alexa Developer Console skill list.
- Delete the execution role linked from the Private Jukebox Lambda function configuration in IAM.
- Delete the Private Jukebox log group from CloudWatch Logs.
- Delete the Private Jukebox AWS Lambda Event Function with its attached Alexa Skills Kit trigger.
- Delete tracks/track-001.mp3, tracks/track-002.mp3, tracks/track-003.mp3, and catalog.json from the object list inside alexa-private-jukebox.
- Delete the empty alexa-private-jukebox bucket from its Settings page.
- Revoke the uploader token plus the playback token from the R2 API token page.
- Remove config.local.json from the private-alexa-jukebox project tree.
- Cancel Claude Pro from the subscription settings for your paid Claude account if you no longer need it.
Deletion Is Permanent
Deleting the bucket removes the uploaded tracks plus the cloud catalog. Revoked R2 credentials cannot be used to restore them.
Nice Work!
Nice Work!
You pulled it off! Your Amazon Echo now plays your three owned songs from a private Cloudflare R2 library when you ask Alexa by title, artist, or shuffle.
Private playback stays usable without exposing the bucket. Your AWS Lambda function creates temporary links for each request.
You've learned how to:
- Used MP3 metadata to build a searchable catalog from the title and artist tags in exactly three owned files. Uploaded each track plus catalog.json to your private bucket.
- Kept upload access in a bucket-scoped write credential. Gave Lambda a separate read-only credential. Verified that unsigned access is denied. Proved that a presigned URL works briefly before expiring with HTTP 403.
- Built Alexa intents that recognize song titles, artists, or shuffle requests. Deployed the fulfillment code to AWS Lambda. Heard your Echo stream a private song through an AudioPlayer.Play directive.
- Secret Mission: Extended shuffle with AudioPlayer.PlaybackNearlyFinished so a different private track queues automatically without another voice request.
Ready to quiz yourself?