Ship Data Apps with AI

📣 Grow & reach·Lesson 22 of 26·~30 min·Intermediate

📲 Make it an app on their phone

A manifest, icons, a service worker that never serves yesterday, an install button that works on iPhone, and reminders that store nobody's keys.

You'll leave with

  • Your app installable on Android and iPhone, with icons rendered from one SVG
  • A network-first service worker that never caches account data
  • Optional daily reminders sent as empty web pushes from your own server

A bookmark gets forgotten; an icon on the home screen gets opened. First Bites, my first-foods tracker for babies, gets used at the highchair, so it had to open like an app and work offline, with no app store involved. A website that installs like this is called a PWA. It takes one small file, three icons and, for offline use, one short script.

What's a PWA?

A progressive web app: an ordinary website that phones and computers can install, with its own icon and its own window, no address bar.

⚡ The 60-second version

  1. A manifest (a small file naming your app and its icons) plus three PNG icons rendered from one SVG. Chrome and Edge can install that already.
  2. A service worker (a script the browser runs between your page and the network) that fetches fresh files first and never caches /api/.
  3. An always-enabled install button: Android's dialog, or "Tap Share, then Add to Home Screen" on iPhone.
  4. Optional: daily reminders as empty pushes, sent hourly from your server.
1

What “installable” takes

Less than you'd think. Acento, my Argentine Spanish trainer, has no service worker at all, and current Chrome and Edge still offer to install it: it has a manifest, icons and a few tags in its <head>. Start there.

What's a manifest?

A small JSON file, manifest.webmanifest, telling the phone your app's name, icons, colors and start page. Its "display": "standalone" line makes the installed app open in its own window, not a browser tab.

The icons are where phones differ:

  • iPhone takes its home-screen icon from the apple-touch-icon, a 180×180 PNG. Not an SVG: that shows up blank or wrong.
  • Android cuts icons into its own shape, often a circle, and wants a padded 512-pixel maskable icon, or your design's edges get cropped.
  • The manifest lists a 192 and a 512; Chrome wants both before it offers to install.
What's a maskable icon?

An icon drawn edge to edge with everything important in the middle, so the phone can cut it into a circle or a rounded square. The safe zone is the central circle, 80% of the icon's width.

All of them come from one SVG (an image made of shapes, sharp at any size), photographed by a script at each size. For Baby Signs, my baby sign language app, Edge's quick screenshot flag (msedge --headless --screenshot --window-size=192,192) drew the SVG at its full 512 pixels inside the smaller window: cropped corners instead of icons. Driving the browser over the DevTools Protocol, setting both the window and the SVG to each size, fixed it.

What are a headless browser and the DevTools Protocol?

A headless browser is a real Chrome or Edge with no window, run by a script. The DevTools Protocol (CDP) is the remote control DevTools itself uses: "make the window 192 pixels wide", "take a screenshot". Both appear in Check the AI's work.

Ask Claude Code, the AI assistant in your VS Code project. Did you move your app to /app/ in Get found on Google? Say so: start_url, scope and the service worker's paths must follow.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouMake this app installable, no service worker yet: manifest.webmanifest (id "/", start_url /?pwa=1, display standalone, my --bg color, 192 and 512 icons plus the 512 as maskable), icon.svg with my favicon's letter inside the middle 80%, and icons.mjs to render the 512, 192 and 180 PNGs over the DevTools Protocol. Add the head tags.
Claudenode icons.mjs printed 512x512, 192x192 and 180x180, and I opened all three: the whole letter shows in each. index.html now links the manifest and the apple-touch-icon. Ship those five files.↳ Write · manifest.webmanifest, icon.svg, icons.mjs↳ Edit · index.html↳ Bash · node icons.mjs↳ Read · the three PNGs

What's happening underneath. The manifest (use your app's name and your --bg color):

{
  "id": "/",
  "name": "MyApp: what it does in a few words",
  "short_name": "MyApp",
  "start_url": "/?pwa=1",
  "scope": "/",
  "display": "standalone",
  "background_color": "#f2f2f7",
  "theme_color": "#f2f2f7",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

Never change id once people have installed: it's the app's identity. ?pwa=1 lets your analytics count home-screen visits, as on First Bites and Grease. With the letter inside the safe zone, one 512 PNG serves as normal and maskable icon; if yours has rounded or see-through corners, render a separate, padded maskable file. In <head>, beside your theme-color tag from Make it look right everywhere:

<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">

The last tag is for older iPhones. The drawing, in your accent and letter:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#0062cc"/>
  <text x="256" y="322" font-family="system-ui, sans-serif" font-size="190" font-weight="700"
        text-anchor="middle" fill="#ffffff">M</text>
</svg>

And the renderer (Node 22 or newer):

// icons.mjs: render icon.svg at every size phones need, over the DevTools Protocol. Run: node icons.mjs
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const SIZES = { 'icon-512.png': 512, 'icon-192.png': 192, 'apple-touch-icon.png': 180 };
const BROWSER = process.env.BROWSER || {
  win32: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
  darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
}[process.platform] || 'google-chrome';
const svg = readFileSync('icon.svg', 'utf8');
const profile = mkdtempSync(join(tmpdir(), 'icons-'));
const browser = spawn(BROWSER, ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile, 'about:blank']);
try {
  const port = await new Promise((ok, fail) => {
    browser.on('error', fail);
    browser.stderr.on('data', d => { const m = /DevTools listening on ws:\/\/[^:]+:(\d+)/.exec(d); if (m) ok(m[1]); });
  });
  const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
  const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
  await new Promise(r => ws.addEventListener('open', r));
  let n = 0; const waiting = new Map();
  ws.addEventListener('message', m => { const d = JSON.parse(m.data); waiting.get(d.id)?.(d); });
  const send = (method, params = {}) => new Promise((ok, fail) => {
    waiting.set(++n, d => {
      const err = d.error?.message || d.result?.exceptionDetails?.exception?.description;
      err ? fail(new Error(method + ': ' + err)) : ok(d.result);
    });
    ws.send(JSON.stringify({ id: n, method, params }));
  });
  await send('Runtime.evaluate', { expression: `document.body.style.margin = '0'; document.body.innerHTML = ${JSON.stringify(svg)}` });
  for (const [file, size] of Object.entries(SIZES)) {
    await send('Emulation.setDeviceMetricsOverride', { width: size, height: size, deviceScaleFactor: 1, mobile: false });
    await send('Runtime.evaluate', { expression: `(s => { s.setAttribute('width', ${size}); s.setAttribute('height', ${size});
      s.style.display = 'block' })(document.querySelector('svg'))` });
    const { data } = await send('Page.captureScreenshot', { format: 'png' });
    writeFileSync(file, Buffer.from(data, 'base64'));
    console.log(`${file}  ${size}x${size}`);
  }
  ws.close();
} finally {
  browser.kill();
  await new Promise(r => setTimeout(r, 500));
  rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
}

It opens the browser windowless with a throwaway profile (a blank set of settings), puts the SVG on an empty page, and for each size sets the window and the SVG, then takes a screenshot. A failed command stops it with an error, never a quietly cropped icon. If it can't find your browser, set BROWSER to its path, or ask Claude to. Run node icons.mjs:

icon-512.png 512x512 icon-192.png 192x192 apple-touch-icon.png 180x180

Each PNG, opened in VS Code, should show the whole design. icon.svg and icons.mjs stay on your laptop; ship the rest with the allowlist tarball (one packed file) from Deploy behind one front door; PowerShell works too:

tar -czf release.tgz index.html manifest.webmanifest icon-512.png icon-192.png apple-touch-icon.png
scp release.tgz root@YOUR_VPS_IP:/tmp/
ssh root@YOUR_VPS_IP 'gzip -t /tmp/release.tgz && tar -xzf /tmp/release.tgz -C /root/myapp && rm /tmp/release.tgz'

Then check the manifest's label with curl, which shows a server's raw answer (PowerShell: curl.exe):

curl -sI https://myapp.yourdomain.com/manifest.webmanifest

Among the lines, look for this content-type header, the label a server sends with every file (capitals may vary):

content-type: application/manifest+json

Check it on real devices. In Chrome or Edge on a computer (Safari and phones can't follow this part), open DevTools, the browser's built-in developer tools, with F12 (Mac: Cmd+Option+I), then Application (behind » if hidden), then Manifest: your name and icons, no errors. Tick Show only the minimum safe area for maskable icons; your letter should stay whole. Then install it:

  • Android, Chrome: ⋮, then Install app or Add to home screen (wording varies by version).
  • Computer, Chrome or Edge: the install icon at the right end of the address bar, or the same item in the menu.
  • iPhone, Safari: Share (where Safari puts it varies by iOS version), then Add to Home Screen, then Add.

Your icon should land on the home screen and open with no address bar.

🦥 What we skip: app stores, native code and any framework's PWA plugin. A service worker comes only because offline use and reminders need one.

2

A service worker that never serves yesterday

Offline needs a service worker, and a service worker can also trap people on an old version: a cache-first one answers from saved copies, so after you ship a fix, everyone who installed keeps last month's app. The cure is network-first.

What's a service worker?

A small script, sw.js, that your page registers once. From then on the browser runs it between your page and the network, so it can answer requests from saved copies: that's what makes offline work, and what can serve stale files.

What do network-first and cache-first mean?

Network-first asks the internet and uses the saved copy only when that fails: always fresh when online. Cache-first answers from the saved copy and goes online only when it has none: fast, and stale after every update.

  • Rename the cache every release (myapp-v1, -v2…) and delete the old names on activate. The browser notices a new worker only when sw.js changes, and the new name is that change. Habooby, my Phoenix dust map, went from v1 to v2 when a fix shipped. When Baby Signs moved its app to /app/, the start page, cache name and offline fallback all had to move too.
  • Cache only your own files. Habooby installs to the home screen, but its worker never touches the live data it fetches from other services, so that data is never stale.
  • Upload every listed file before the new worker. cache.addAll fails if even one is missing, and then the new worker never installs.

check.mjs is the headless-browser check from Check the AI's work; Claude writes one if you have none.

What are GET and an origin?

GET is a request that only reads, like loading a page; saving and logging in use POST and its cousins. An origin is https:// plus a site's full name, like https://myapp.yourdomain.com; another site's script has its own.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouAdd a network-first sw.js, registered from index.html: same-origin GET only, never /api/; copies saved by path only; a cached, branded 404.html for unknown pages; the last good copy on a 5xx or offline; cache myapp-v1, old caches deleted on activate. Then list Cache Storage with check.mjs.
Claudesw.js is activated. Cache Storage holds one cache, myapp-v1, with the 7 SHELL files and nothing from /api/. Test offline yourself in DevTools.↳ Write · sw.js, 404.html↳ Edit · index.html, check.mjs↳ Bash · node check.mjs

What's happening underneath. The whole worker; this lesson never replaces it (the push lines at the bottom wait for step 4):

// sw.js: network-first. The cache is only for offline moments and server errors.
const CACHE = 'myapp-v1';   // rename (myapp-v2, -v3...) on every release
const SHELL = ['/index.html', '/app.js', '/style.css', '/manifest.webmanifest',
  '/icon-192.png', '/icon-512.png', '/404.html'];   // only files your app has; each must exist

self.addEventListener('install', e => e.waitUntil(
  caches.open(CACHE)
    .then(c => c.addAll(SHELL.map(u => new Request(u, { cache: 'reload' }))))
    .then(() => self.skipWaiting())));

self.addEventListener('activate', e => e.waitUntil(
  caches.keys()
    .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
    .then(() => self.clients.claim())));

self.addEventListener('fetch', e => {
  const req = e.request, url = new URL(req.url);
  if (req.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return;
  const key = url.pathname === '/' ? '/index.html' : url.pathname;   // the path only: ?join=CODE never becomes a key
  const page = req.mode === 'navigate';
  const lastGood = () => caches.match(key).then(c => c || (page ? caches.match('/index.html') : undefined));
  e.respondWith(fetch(req, { cache: 'no-cache' }).then(r => {
    if (r.status === 200 && r.type === 'basic') {
      const copy = r.clone();
      caches.open(CACHE).then(c => c.put(key, copy));
      return r;
    }
    if (r.status === 404 && page) return caches.match('/404.html').then(c => c || r);
    if (r.status >= 500) return lastGood().then(c => c || r);   // a 502 mid-deploy
    return r;
  }, () => lastGood().then(c => c || Response.error())));        // offline
});

// Reminders (step 4): pushes arrive empty, so the message lives here.
self.addEventListener('push', e => e.waitUntil(self.registration.showNotification('MyApp',
  { body: 'Time for a quick practice.', icon: '/icon-192.png', tag: 'daily' })));
self.addEventListener('notificationclick', e => {
  e.notification.close();
  e.waitUntil(clients.openWindow('/?pwa=1'));
});
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>

install saves fresh copies of SHELL, and skipWaiting lets the new worker take over at once. activate deletes every other cache. fetch steps aside for anything that isn't a same-origin GET, or is /api/. Everything else asks the server first (no-cache: an unchanged file costs a tiny "not modified"), saves good answers under their path, and uses a saved copy only on a server error or offline.

Ship 404.html and every SHELL file before sw.js, or all in one tarball, then check them (Git Bash or the server; use your own list):

for f in /index.html /404.html /manifest.webmanifest /icon-192.png /icon-512.png /sw.js; do
  curl -s -o /dev/null -w "%{http_code} $f\n" https://myapp.yourdomain.com$f
done

Every line should start with 200. In DevTools, Application, Service workers should say activated and is running, and Cache storage (left column) should list myapp-v1. Tick Offline in the Service workers pane and reload: the app still appears. Untick it after.

💡 While building, tick Update on reload there. New version never arrives? An addAll error in the Console means a SHELL file is missing on the server.

What it must never cache

The worker sees every request your page makes, so it can store things that should never sit on a phone. One line at the top of its fetch handler is the policy:

if (req.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return;
  • Never /api/: account data and Pro status (whether someone paid). Cached, a phone shows yesterday's Pro status, breaks login, or keeps private data after sign-out. Grease and Handstand, my training apps, skip it for that reason.
  • Never other sites' files (your analytics script) and never anything but GET: a save must reach the server or fail visibly.
  • Pages by path only: /?join=CODE is filed as /index.html, so an invite code never becomes a cache key (the name a saved copy is filed under).
  • The last good copy during a deploy's brief 502, and a cached, branded 404 for unknown pages.
What are 404 and 502?

Status codes, the three-digit verdict on every response: 200 is OK, 404 "no such page", and 502 means the front door couldn't reach your app, as for a few seconds while its container restarts.

📍 First Bites' first service worker saved API responses, so account data sat in phones' caches. Its second fixed five things at once: a new cache name, so the old cache was deleted; no /api/; pages by path only; the last good copy during a mid-deploy error; and a cached 404, because the Android app wrapper treats an error page as a crash. That wrapper is how a website becomes a Play Store app: Ship to Google Play.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouIn check.mjs, open /?join=TEST123 and make one /api/ request, then list every Cache Storage key, failing on any with /api/ or a question mark. Then open /no-such-page and tell me what it shows.
Claude7 keys, all plain paths; /?join=TEST123 was filed as /index.html, and nothing came from /api/. /no-such-page shows your 404.html, heading and link home.↳ Edit · check.mjs↳ Bash · node check.mjs

What's happening underneath: check.mjs reads the same list DevTools shows. To see the 502 fallback at a quiet moment: docker stop myapp on the server, reload the installed app (it still opens), then docker start myapp and confirm curl -I shows 200 (if not, reload Caddy as in Deploy). Others see an error meanwhile.

3

The iPhone traps

Chrome and Edge send a beforeinstallprompt event when your app can be installed; your page keeps it to open the native install dialog later. iPhone Safari never sends it.

What's an event?

A signal the browser gives your code when something happens: a tap, the page becoming visible, Chrome deciding your app is installable. addEventListener says what runs then.

📍 A real user reported "add to homescreen not working". First Bites' install button stayed disabled until beforeinstallprompt arrived, so every iPhone user, and people on some desktop browsers, saw a dead grey button with no hint of what to do.

So keep the button enabled and decide on tap: Android's dialog, the Share tip on iPhone, a menu tip elsewhere. Label it with the benefit, like Habooby's "Open like an app", and hide it once the app runs installed. Grease shows it as a phone-only banner that stays dismissed.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouAdd an install button, Open like an app, never disabled. On tap: call a saved beforeinstallprompt's prompt(); on iPhone or iPad show "Tap Share, then Add to Home Screen"; otherwise "Open your browser menu and choose Install app". Hide it when installed.
ClaudeDone. In check.mjs at 390px it's enabled, and a tap shows the menu tip, or the Share tip with an iPhone user agent. Hiding when installed needs a real phone.↳ Edit · index.html, check.mjs↳ Bash · node check.mjs

What's happening underneath:

<style> [hidden] { display: none !important; } </style>
<button id="install" class="btn">Open like an app</button>
<p id="installTip" hidden></p>
<script>
  let deferredPrompt = null;
  const installBtn = document.getElementById('install'), tip = document.getElementById('installTip');
  if (matchMedia('(display-mode: standalone)').matches || navigator.standalone) installBtn.hidden = true;
  addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredPrompt = e; });
  addEventListener('appinstalled', () => { deferredPrompt = null; installBtn.hidden = true; });
  installBtn.addEventListener('click', () => {
    if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt = null; return; }
    const apple = /iPhone|iPad|iPod/.test(navigator.userAgent)
      || (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);   // iPads say "Macintosh"
    tip.textContent = apple ? 'Tap Share, then Add to Home Screen.' : 'Open your browser menu and choose Install app.';
    tip.hidden = false;
  });
</script>
What's a user agent?

A line of text every browser sends to describe itself: browser, system, device. A rough guide, not proof; iPads describe themselves as Macs, hence the touch-screen check.

The [hidden] line matters: a button whose CSS sets display, like .btn from Make it look right everywhere, ignores hidden without it. display-mode: standalone is true inside the installed app. Test on a real iPhone in Safari and an Android phone in Chrome; headless browsers show neither dialog.

The keyboard that hides your form

In an installed iPhone app the keyboard doesn't shrink the page, so First Bites' bottom sheets (forms that slide up from the bottom) ended up behind it, hiding what people typed. The fix sizes the scrim, the dimmed layer behind the sheet, to the visual viewport: the part of the screen the keyboard leaves.

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<style> .sheet { max-height: min(92vh, 100%); overflow: auto; } </style>
<script>
  function fitScrim() {
    const sc = document.querySelector('.scrim'), vv = window.visualViewport;
    if (!sc || !vv) return;
    sc.style.top = vv.offsetTop + 'px';
    sc.style.height = vv.height + 'px';
    sc.style.bottom = 'auto';
  }
  window.visualViewport?.addEventListener('resize', fitScrim);
  window.visualViewport?.addEventListener('scroll', fitScrim);
</script>

The viewport line covers browsers that support it, like Chrome on Android; the script covers iPhone. Ask Claude: "My bottom sheet hides behind the iPhone keyboard in the installed app; size .scrim to window.visualViewport." Check on a real iPhone, installed: the field should sit above the keyboard.

Pages that feel like a controller

A browser party game of mine turns guests' phones into controllers, nothing installed. Pointer capture keeps a held button pressed when a thumb slides off. A wake lock stops the screen dimming; the browser drops it when the page hides, so ask again. Vibration is a bonus: some phones ignore it.

const gas = document.getElementById('gas');
gas.addEventListener('pointerdown', e => gas.setPointerCapture(e.pointerId));
const keepAwake = () => navigator.wakeLock?.request('screen').catch(() => {});
keepAwake();
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') keepAwake();
});
navigator.vibrate?.(30);   // a bonus, never the only feedback

How web push works, and its iPhone catch

Reminders bring people back. How a push travels:

your page    ── subscribe, with your public key ──▶  the browser's push service
             ◀── an endpoint: a long URL for this one browser ──
your page    ── endpoint + hour + time zone ──▶  your server, which stores only those
your server  ── each hour: an empty POST + a signed token ──▶  that endpoint
push service ── wakes ──▶  your sw.js, which shows the reminder
What are a push service and an endpoint?

Each browser maker runs a push service: Google's for Chrome, Apple's for Safari, Mozilla's for Firefox, Microsoft's for Edge. When someone allows notifications, it gives the browser an endpoint, a long, unguessable URL; a POST request to it wakes that browser.

What's VAPID?

Your server's own key pair for web push. The public half goes in your page; the private half signs a short token on every push, proving it comes from the server the browser subscribed with. The token is a JWT: a little JSON plus a signature.

The iPhone catch: on iPhone (iOS 16.4 and later), web push works only for apps installed to the home screen, one more reason for "display": "standalone", and permission must be asked from a tap, never on page load. So reminders build on everything above.

Targeting comes before sending

If every event notifies everyone, people mute your app. On a river-flow side project of mine with permit alerts, one permit release for a popular river would have meant about 20 notifications each. So people choose what they care about, every filter they set must match, and one pure function with 17 assertions decides.

What's a pure function?

A function whose answer depends only on what you pass in: no network, no clock, no database. Same inputs, same answer, so assertions (lines that stop with an error when a result is wrong) can test each filter alone and combined.

// Does this person want to hear about this? Every filter they set must match.
function alertMatches(f, release, entry) {
  return (!f.items || f.items.includes(release.itemId))
    && (!f.from || entry.date >= f.from) && (!f.to || entry.date <= f.to)
    && (!f.minSpots || entry.remaining >= f.minSpots);
}

For a daily reminder, the hour someone picks is the targeting.

4

Empty pushes, signed by hand

A push that carries text must be encrypted separately for each subscriber, using keys their browser gives you, so you'd store everyone's keys. An empty push needs only the endpoint and your signed token; the service worker wakes and shows a fixed reminder (the push lines in your sw.js). That's Baby Signs' daily reminder: the message never changes, so it never travels. Store three things per subscriber: endpoint, chosen hour, time zone. No names, emails or keys.

Keys that never touch the web root

Make the key pair once, on the server, into a file only root can read. Run this yourself, so the private key never appears in a chat, Claude's included:

mkdir -p /root/secrets && chmod 700 /root/secrets
umask 077
test -e /root/secrets/myapp-push.env || docker run --rm node:24-alpine node -e "const e=require('crypto').createECDH('prime256v1');e.generateKeys();console.log('VAPID_PUBLIC='+e.getPublicKey('base64url'));console.log('VAPID_PRIVATE='+e.getPrivateKey('base64url'))" > /root/secrets/myapp-push.env
grep VAPID_PUBLIC /root/secrets/myapp-push.env

umask 077 makes new files root-only; test -e refuses to overwrite existing keys, which would orphan every subscription. The last line prints the public key for your page. Then add the lines below with nano /root/secrets/myapp-push.env, no quotes: your own address and a login for the sender's own superuser (you create it in step 5). No PocketBase? Skip the PB_ lines.

VAPID_SUBJECT=mailto:you@yourdomain.com
PB_URL=http://pb:8090
PB_EMAIL=sender@yourdomain.com
PB_PASSWORD=A_LONG_RANDOM_PASSWORD
What's an env file?

A plain text file of NAME=value lines. docker run --env-file hands each line to the program as an environment variable, which Node reads as process.env.NAME, so the values never sit in your code or your repo.

🛑 Never inside /root/myapp, which is the website. When I added reminders to one of my apps, its keys file sat in the app's folder for a few minutes, downloadable by anyone who guessed the name. I moved it and rotated the keys anyway. If a key is ever served, rotate: a new pair, the old one deleted, and the page re-subscribing anyone still on the old key. Annoying, and still right.

Then ask Claude for the code:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWrite push-send.js with Node built-ins only: sign with crypto.sign and dsaEncoding 'ieee-p1363', keys from process.env. Add --selftest (sign with a throwaway key pair and verify; check the local-hour math for Phoenix, Tokyo and a midnight wrap-around) and --probe (one empty push to https://fcm.googleapis.com/fcm/send/made-up; print the status). In app.js, add remindMe(hour) behind a "Remind me" button with an hour picker; if push isn't available, say why. In sw.js, show a fixed reminder on push and open /?pwa=1 when it's tapped.
Claudenode push-send.js --selftest passes: the token verifies, and the hours come out 9 in Phoenix, 21 in Tokyo and 0 past midnight. Run --probe on the server in step 5, where the real keys are; 410 means accepted.↳ Write · push-send.js↳ Edit · app.js, index.html, sw.js↳ Bash · node push-send.js --selftest

What's happening underneath, starting with the browser's half:

const VAPID_PUBLIC = 'YOUR_VAPID_PUBLIC';   // the public key only, never the private one
async function remindMe(hour) {
  if (!('PushManager' in window)) return false;   // iPhone: only once installed to the home screen
  if (await Notification.requestPermission() !== 'granted') return false;
  const reg = await navigator.serviceWorker.ready;
  const key = Uint8Array.from(atob(VAPID_PUBLIC.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
  const sub = await reg.pushManager.getSubscription()
    || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;   // like America/Phoenix
  const r = await fetch('/api/collections/myapp_push/records', {  // or your small server's route
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ endpoint: sub.endpoint, hour, tz }) });
  return r.ok;
}

Replace YOUR_VAPID_PUBLIC with the part after VAPID_PUBLIC= that grep printed. userVisibleOnly: true promises every push shows a notification, which browsers require. The browser's own encryption keys are never sent. To stop reminders, the page calls the subscription's unsubscribe(); the sender deletes the record once the push service reports it gone.

The server's half: a token signed with node:crypto

Node signs in a format called DER by default; push services want the raw 64-byte form. Without dsaEncoding: 'ieee-p1363' the push service answers 401 or 403 (token refused); Google's answers 403.

const crypto = require('node:crypto');
const part = o => Buffer.from(JSON.stringify(o)).toString('base64url');

function vapidToken(endpoint, env = process.env) {
  const pub = Buffer.from(env.VAPID_PUBLIC, 'base64url');   // 65 bytes: 4, then x and y
  const key = crypto.createPrivateKey({ format: 'jwk', key: { kty: 'EC', crv: 'P-256', d: env.VAPID_PRIVATE,
    x: pub.subarray(1, 33).toString('base64url'), y: pub.subarray(33).toString('base64url') } });
  const unsigned = part({ typ: 'JWT', alg: 'ES256' }) + '.' + part({
    aud: new URL(endpoint).origin,                     // the push service's address
    exp: Math.floor(Date.now() / 1000) + 12 * 3600,    // good for 12 hours
    sub: env.VAPID_SUBJECT });
  const sig = crypto.sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' });
  return unsigned + '.' + sig.toString('base64url');
}

async function sendEmpty(endpoint) {
  const r = await fetch(endpoint, { method: 'POST', headers: {
    TTL: '3600',   // if the phone is off, keep trying for an hour
    Authorization: `vapid t=${vapidToken(endpoint)}, k=${process.env.VAPID_PUBLIC}` } });
  return r.status;   // 201 sent; 404 or 410 gone; 401 or 403 bad token
}

--probe proves the signature before anyone subscribes: one push to a made-up endpoint on Google's push service. 410 Gone means the signature was accepted and the subscription doesn't exist (true: you made it up); 401 or 403 means the token is wrong.

5

The sender and the subscriptions table

Subscriptions need a home where signed-out people can add one and nobody can list them: reminders should work without an account.

  • If you took Accounts without building auth: a PocketBase collection, myapp_push, with endpoint (text), hour (number, 0 to 23) and tz (text). In your dashboard (https://accounts.yourdomain.com/_/), unlock its Create rule and leave it empty; keep the other four locked. Add the sender's superuser there too, with the login from your env file. Wording varies by version; ask Claude.
  • Otherwise: a route in your small server from Add a backend only when forced that saves the three values to a file in its data folder, replacing any entry with the same endpoint, with no route that reads them back. No small server yet? Reminders can wait.
What are a collection and its rules?

In PocketBase, a collection is a table with a ready-made API. Each action (list, view, create, update, delete) has a rule: an empty rule lets anyone do it, a locked one only superusers, PocketBase's admins.

Don't put a unique index on endpoint: changing the hour posts a new record, and a browser that lost track of its record could never sign up again. Let duplicates in; the sender keeps the newest per endpoint and deletes the rest.

What's a unique index?

A database rule that refuses a second row with the same value in a column. Handy for email addresses; here it would turn a harmless repeat into an error the visitor can't fix.

Prove a stranger can't read the list, in Git Bash or on the server (for the file version, a GET on your route must not return it either):

curl -s -o /dev/null -w '%{http_code}\n' https://myapp.yourdomain.com/api/collections/myapp_push/records
403

Now ship your updated app.js, index.html and sw.js the usual way, and on your phone (installed, if it's an iPhone) or in a desktop browser, tap Remind me with the current hour picked. That's your test subscription.

Cron runs the sender every hour in a throwaway Node container (--rm deletes it after). It reads the subscriptions, works out each person's local hour from their time zone, and sends to everyone whose chosen hour is now: their 8 am, not your server's.

What are cron and a time zone name?

Cron is Linux's built-in scheduler: one line in your crontab, its list of jobs, runs a command on a timetable. America/Phoenix is the standard name for a place's clock, daylight saving included; Intl, JavaScript's built-in date tools, does the math.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouFinish push-send.js: log in to PocketBase as the sender (the PB_ lines), list myapp_push, keep the newest record per endpoint and delete the rest, send only to https endpoints on the browsers' push services, and delete records that come back 404 or 410. Add --dry; a real run prints sent N. Write run.sh, copy both to /root/myapp-push, run --selftest, --probe and --dry there, and add an hourly crontab line.
ClaudeOn the server: --selftest passes; --probe got 410, so your signature is accepted; --dry lists 1 subscription, yours, due this hour. crontab -l shows the line. Do the real send yourself.↳ Edit · push-send.js↳ Write · run.sh↳ Bash · scp · ssh root@YOUR_VPS_IP · run.sh --selftest · --probe · --dry · crontab -l

What's happening underneath: the hour math, which the self-test checks against Phoenix, Tokyo and midnight:

const assert = require('node:assert');
const localHour = (tz, now = new Date()) =>
  Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', timeZone: tz }).format(now));

if (process.argv.includes('--selftest')) {
  assert.equal(localHour('America/Phoenix', new Date('2026-01-15T16:00:00Z')), 9);
  assert.equal(localHour('Asia/Tokyo', new Date('2026-01-15T12:00:00Z')), 21);
  assert.equal(localHour('Asia/Tokyo', new Date('2026-01-15T15:00:00Z')), 0);   // past midnight
}

The sender lives beside the website's folder, never inside it, and run.sh starts it with the env file's keys and the code read-only:

#!/bin/sh
exec docker run --rm --network web --env-file /root/secrets/myapp-push.env \
  -v /root/myapp-push:/app:ro node:24-alpine node /app/push-send.js "$@"

Then the upload, and one crontab line that runs it at minute 0 of every hour, appending its output to a log (crontab -e opens your crontab in an editor; pick nano if asked):

ssh root@YOUR_VPS_IP 'mkdir -p /root/myapp-push'
scp push-send.js run.sh root@YOUR_VPS_IP:/root/myapp-push/
ssh root@YOUR_VPS_IP 'chmod +x /root/myapp-push/run.sh'
0 * * * * /root/myapp-push/run.sh >> /var/log/myapp-push.log 2>&1

Why only push-service addresses? Anyone can add an endpoint, and a prankster could otherwise make your server send requests anywhere, your own containers included. For the file version, add your data folder to run.sh read-only (like -v /root/myapp-data:/data:ro) and give your server a route that deletes one endpoint, so the server stays the only writer.

Then one real run within that same hour (if it has rolled over, pick the new hour and tap again): run /root/myapp-push/run.sh on the server.

sent 1

The notification should arrive within seconds. That's how Baby Signs' sender was proven: a real browser subscribed, the push service accepted the push, sent 1. Wrong hour? Check the record's tz first.

🦥 Skipped on purpose: pushes with text, a web-push library, a job queue. Need details one day? Keep the push empty and let the service worker fetch them from your server.

Do it with Claude: the prompts

The whole lesson in three prompts; check each as the steps above did:

  1. "Make this app installable: manifest.webmanifest, 512, 192 and 180 PNG icons rendered from icon.svg, and a network-first sw.js that never caches /api or other origins."
  2. "Add an install button that uses beforeinstallprompt on Android and shows Share, then Add to Home Screen on iPhone."
  3. "Add an optional daily reminder: empty web pushes signed with node:crypto, and an hourly sender with --dry and --selftest."

What's happening underneath: the service worker is a small script the browser runs between your page and the network, which is why it can make the app work offline and also why it can serve stale files. Hence network-first, a new cache name every release, and nothing from /api/.

Your turn

  • Ship the manifest and icons. Done when curl -sI shows application/manifest+json and your icon opens the app, with no address bar, on an iPhone and on an Android phone or computer.
  • Run node icons.mjs. Done when it prints all three sizes and DevTools' maskable safe-area view shows your whole symbol.
  • Ship sw.js. Done when DevTools shows it activated, Cache storage holds only myapp-v1 with no /api/ or ? in any key, and an Offline reload still opens the app.
  • Tap your install button on a real iPhone and Android phone. Done when the iPhone shows the Share tip, Android opens its dialog, and the installed app has no button.
  • Optional reminders. Done when --selftest passes, --probe prints 410, a stranger's list request gets 403, ls -l on the push env file starts with -rw-------, and a real run prints sent 1 as the reminder appears.

Next, Ship to Google Play wraps this same site into an Android app for the Play Store, which is where that branded 404 page earns its keep.