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 APL idleTimeout.
  • 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.RenderDocument with timeoutType: "LONG"
    • speak a whispered “ok” + reprompt 9s, do not end session
  • APL 1.1 doc (max compatibility):
    • full-screen Pager bound to datasource
    • per-page duration in AutoPage (onMount)
    • optional gradient background

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 collisions
  • Pager.data = ${ds.items}
  • AutoPage.duration = ${ds.slideMs} (per page)
  • settings.idleTimeout to 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 backgroundColor with 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 frame Optional: Wait 9m to debounce; DND On for 60m.

7) Troubleshooting (the real stuff I hit)#

SymptomRoot causeFix
“Video service not available”Using VideoApp/APL VideoTurn Video interface OFF. Use APL Text only.
“This device doesn’t support visuals”Over-strict interface checkDon’t hard-gate; try render directly. Ensure APL interface ON, right locale.
Black screen in simulatorPager not bound / bad unitsPut data on Pager, use %/dp (not vh/vw).
Closes after 10–20sSession ended or SSML invalidtimeoutType: 'LONG', do not end session; add valid speech + ≤9s reprompt.
“Invalid SSML… Value ’20s’”Break >10sUse 9s max.
“SSML does not contain any speech content”Empty SSMLSpeak a whisper: <prosody volume="x-soft">ok</prosody>.
Repeats order on restartsNew shuffle each launchDeterministic daily seed; optional LS persistence if you ever add stateful intents.
Works in simulator, not on deviceLocale/profile mismatchAdd 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 @viewportProfile if 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.json in 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.