Echo Show Stoic Frame: APL-only, rock-solid rotating quotes (no Silk, no Video)
10/4/2025
Goal: Run a calm, always-on Stoic quote rotator on Echo Show without the Silk browser or VideoApp, with deterministic shuffling and no session drops.
TL;DR#
- Use a Custom Alexa skill with APL only (no Video, no browser).
- Render a Pager that binds directly to your quotes list. Autopage in
onMount. - Keep the session alive with a minimal valid SSML + ≤9s reprompt.
- Mark the visual as long-lived:
timeoutType: "LONG"and APLidleTimeout. - Deterministic daily shuffle = no repeats until the full cycle.
- Launch by voice or routine; it just keeps rotating.
Why not Silk / Video?#
- Silk often idles and closes; workarounds are fragile.
- VideoApp/APL Video aren’t reliably available on all Echo Show builds (esp. some IN models).
- APL-only works everywhere that reports
Alexa.Presentation.APL.
Final architecture#
- Alexa Hosted (Node.js) backend with a single Launch handler:
- fetch
quotes.json(Cloudflare Pages or any public URL) - shuffle (daily seed)
- send one
APL.RenderDocumentwithtimeoutType: "LONG" - speak a whispered “ok” + reprompt 9s, do not end session
- fetch
- APL 1.1 doc (max compatibility):
- full-screen
Pagerbound to datasource - per-page duration in
AutoPage(onMount) - optional gradient background
- full-screen
1) Create the skill#
- Developer Console → Create Skill → Name:
Stoic Frame - Model: Custom
- Backend: Alexa-Hosted (Node.js)
- Interfaces:
- ON: Alexa Presentation Language (APL)
- OFF: Video
- (Optional) Display Interface ON only if you want a legacy fallback
Add/enabled locales your device uses (e.g., en-IN). Build the model after toggles.
2) apl.json (APL 1.1, bullet-proof)#
Key details:
parameters: ["ds"]to avoid binding collisionsPager.data = ${ds.items}AutoPage.duration = ${ds.slideMs}(per page)settings.idleTimeoutto keep viewport alive
{
"type": "APL",
"version": "1.1",
"theme": "dark",
"settings": { "idleTimeout": 600000 },
"mainTemplate": {
"parameters": ["ds"],
"items": [
{
"type": "Container",
"width": "100%",
"height": "100%",
"backgroundColor": "#111827",
"items": [
{
"type": "Pager",
"id": "pager",
"navigation": "wrap",
"width": "100%",
"height": "100%",
"data": "${ds.items}",
"items": [
{
"type": "Container",
"width": "100%",
"height": "100%",
"paddingLeft": "8%",
"paddingRight": "8%",
"justifyContent": "center",
"alignItems": "center",
"items": [
{
"type": "Text",
"text": "${data.text}",
"fontSize": "26dp",
"fontWeight": "700",
"color": "#e5e7eb",
"textAlign": "center",
"maxLines": 6
},
{
"type": "Text",
"text": "${data.author ? '— ' + data.author : ''}",
"fontSize": "18dp",
"color": "#94a3b8",
"textAlign": "center",
"paddingTop": "18dp"
}
]
}
],
"onMount": [
{
"type": "AutoPage",
"componentId": "pager",
"duration": "${ds.slideMs}"
}
]
}
]
}
]
}
}
Optional: replace
backgroundColorwith a gradient:"backgroundGradient": { "type": "linear", "colorRange": ["#0f172a","#111827"], "inputRange": [0,1], "angle": 135 }
3) index.js (CommonJS, Node 16 compatible)#
- No ESM imports.
- One render directive with
timeoutType: 'LONG'. - Minimal valid speech + ≤9s reprompt.
'use strict';
const Alexa = require('ask-sdk-core');
const https = require('https');
const APL_DOC = require('./apl.json');
// ---------- CONFIG ----------
const QUOTES_URL = 'https://<your-domain>/quotes.json'; // host on CF Pages/S3/etc.
const SLIDE_MS = 45000; // per-page
// ---------- UTIL ----------
function dailySeed() {
const d = new Date();
return (
(d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate()) >>> 0
);
}
function shuffle(arr, seed) {
let t = seed >>> 0;
function rnd() {
t += 0x6d2b79f5;
let x = Math.imul(t ^ (t >>> 15), 1 | t);
x ^= x + Math.imul(x ^ (x >>> 7), 61 | x);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
}
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
function fetchJson(url) {
return new Promise(function (resolve, reject) {
https
.get(url, function (res) {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error('HTTP ' + res.statusCode));
}
let data = '';
res.setEncoding('utf8');
res.on('data', function (chunk) {
data += chunk;
});
res.on('end', function () {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
})
.on('error', reject);
});
}
// ---------- HANDLERS ----------
const LaunchRequestHandler = {
canHandle: h => Alexa.getRequestType(h.requestEnvelope) === 'LaunchRequest',
handle: async h => {
let quotes = [];
try {
const arr = await fetchJson(QUOTES_URL);
if (Array.isArray(arr)) quotes = arr;
} catch (_) {}
if (!quotes.length) {
quotes = [
{
text: 'Waste no more time arguing what a good man should be. Be one.',
author: 'Marcus Aurelius',
},
{
text: 'Man is disturbed not by things, but by the views he takes of them.',
author: 'Epictetus',
},
];
}
quotes = quotes
.filter(q => q && String(q.text || '').trim().length)
.map(q => ({
text: String(q.text).trim(),
author: (q.author || '').trim(),
}));
const items = shuffle(quotes.slice(0, 120), dailySeed());
h.responseBuilder.addDirective({
type: 'Alexa.Presentation.APL.RenderDocument',
token: 'stoic-frame',
document: APL_DOC,
datasources: { ds: { items, slideMs: SLIDE_MS } },
timeoutType: 'LONG',
});
return h.responseBuilder
.speak('<speak><prosody volume="x-soft">ok</prosody></speak>')
.reprompt(
'<speak><prosody volume="x-soft">still here</prosody><break time="9s"/></speak>'
)
.withShouldEndSession(false)
.getResponse();
},
};
const HelpHandler = {
canHandle: h =>
Alexa.getRequestType(h.requestEnvelope) === 'IntentRequest' &&
Alexa.getIntentName(h.requestEnvelope) === 'AMAZON.HelpIntent',
handle: h =>
h.responseBuilder
.speak('Say: open stoic frame.')
.withShouldEndSession(false)
.getResponse(),
};
const StopHandler = {
canHandle: h => {
const isIntent =
Alexa.getRequestType(h.requestEnvelope) === 'IntentRequest';
const name = Alexa.getIntentName(h.requestEnvelope);
return (
isIntent &&
(name === 'AMAZON.StopIntent' || name === 'AMAZON.CancelIntent')
);
},
handle: h =>
h.responseBuilder
.speak('Goodbye.')
.withShouldEndSession(true)
.getResponse(),
};
const SessionEnd = {
canHandle: h =>
Alexa.getRequestType(h.requestEnvelope) === 'SessionEndedRequest',
handle: h => h.responseBuilder.getResponse(),
};
exports.handler = Alexa.SkillBuilders.custom()
.addRequestHandlers(
LaunchRequestHandler,
HelpHandler,
StopHandler,
SessionEnd
)
.withApiClient(new Alexa.DefaultApiClient())
.lambda();
package.json
{
"name": "stoic-frame-skill",
"version": "1.0.0",
"private": true,
"dependencies": {
"ask-sdk-core": "^2.12.0",
"ask-sdk-model": "^1.74.0"
}
}
4) quotes.json format#
[
{
"text": "Waste no more time arguing what a good man should be. Be one.",
"author": "Marcus Aurelius"
},
{
"text": "Man is disturbed not by things, but by the views he takes of them.",
"author": "Epictetus"
},
{ "text": "Ask: is this in my control?", "author": "Epictetus" }
]
Host this anywhere public (Cloudflare Pages works great). Update QUOTES_URL.
5) Invocation & testing#
- Invocation name: 2–3 lowercase words, no prepositions/articles. e.g.,
stoic frame. - Interaction Model → add at least one custom intent with a sample utterance; build model.
- Test tab → set Development for the device’s locale (e.g., en-IN).
- On the Show (same account + adult profile):
“Alexa, open stoic frame” or “Alexa, open stoic frame on
”.
No-voice loop while iterating
Enable Tap to Alexa on the Show and create a tile: open stoic frame. Tap to relaunch.
6) Routines (auto-resume)#
- Alexa app → Routines → +
When: device motion (the Show)
Action: Custom →
open stoic frameOptional: Wait 9m to debounce; DND On for 60m.
7) Troubleshooting (the real stuff I hit)#
| Symptom | Root cause | Fix |
|---|---|---|
| “Video service not available” | Using VideoApp/APL Video | Turn Video interface OFF. Use APL Text only. |
| “This device doesn’t support visuals” | Over-strict interface check | Don’t hard-gate; try render directly. Ensure APL interface ON, right locale. |
| Black screen in simulator | Pager not bound / bad units | Put data on Pager, use %/dp (not vh/vw). |
| Closes after 10–20s | Session ended or SSML invalid | timeoutType: 'LONG', do not end session; add valid speech + ≤9s reprompt. |
| “Invalid SSML… Value ’20s’” | Break >10s | Use 9s max. |
| “SSML does not contain any speech content” | Empty SSML | Speak a whisper: <prosody volume="x-soft">ok</prosody>. |
| Repeats order on restarts | New shuffle each launch | Deterministic daily seed; optional LS persistence if you ever add stateful intents. |
| Works in simulator, not on device | Locale/profile mismatch | Add the device locale (e.g., en-IN), build model, ensure same account adult profile. |
8) Optional niceties#
- Gradient background (see above).
- Font scale per device class (use
@viewportProfileif you want). - “Next quote” intent → re-render with the next page and keep the session.
- Day/Night timing → use two skills or an intent slot to set
SLIDE_MS.
9) Why APL 1.1?#
It renders across the widest set of Show builds (incl. strict ones). If you know your target fleet supports newer APL, you can bump and re-introduce AVG gradients, etc.
Appendix: Minimal Interaction Model (JSON)#
{
"interactionModel": {
"languageModel": {
"invocationName": "stoic frame",
"intents": [
{
"name": "NextQuoteIntent",
"slots": [],
"samples": ["next quote", "show next", "another quote"]
},
{ "name": "AMAZON.HelpIntent", "samples": [] },
{ "name": "AMAZON.StopIntent", "samples": [] },
{ "name": "AMAZON.CancelIntent", "samples": [] }
],
"types": []
}
}
}
What I’d improve next#
- Add a tiny settings intent: “set speed to 30 seconds”, “use night theme”, etc.
- Cache
quotes.jsonin memory for a few minutes to reduce cold starts. - Optional legacy Display fallback if you must support very old hubs.
Ship it. The Show stays calm, quotes rotate, and nothing fights you.