In July I asked Claude for a workday timer — 45 minutes seated, two minutes at the pull-up bar, 45 standing, lunch, repeat. One HTML file, zero dependencies, worked perfectly in a desktop browser. By lunch on a phone it had failed at its one job: the phone never rang. Screen off → tab frozen → setTimeout dead. Web Notifications on mobile are a shade entry at best, and only while the tab lives. Push needs a server. The Notification Triggers API — the one spec that was exactly this — died in an origin trial.
So I built Kapsula: a small Android/iOS app that runs one HTML file (pasted code, a file, or a URL) as a project with real OS alarms. This post is about the three technical decisions that shaped it — the sandbox, the bridge, and the alarm plumbing — plus the bugs I hit on real devices.
Constraint zero: no server, ever
I wanted this project to survive on evenings and weekends, which meant: no backend, no accounts, no sync, no analytics. Everything below follows from that constraint. It rules out push entirely — all notifications must be local, scheduled into the OS. It also means the untrusted code a user pastes from an AI chat must be contained client-side.
The sandbox: an iframe without allow-same-origin
User code runs in:
<iframe sandbox="allow-scripts allow-forms allow-modals allow-popups">
The deliberate omission is allow-same-origin. The document gets an opaque origin, which buys a lot for free:
- no access to the shell's
localStorage, cookies, IndexedDB — or anything else; its origin is unique and empty; -
parent.documentis unreachable; - network requests out of the sandbox are blocked (deliberate for now;
kapsula.fetchwith per-project permission is planned).
One consequence hurts: localStorage inside the sandbox doesn't survive a restart, because the opaque origin is new every time. So persistent state goes through the bridge only, keyed by project. The other consequence is the whole point: code I've never read physically cannot reach beyond its frame except through the five methods I expose.
The bridge: postMessage RPC with a 15-second timeout
The shell injects kapsula-client.js into the <head>. It's a plain RPC over postMessage: every call gets an id, replies come back as {kapsula: true, type: 'reply', id, ok, result}, and calls await a hello handshake before flying. The whole API:
kapsula.app.info() // {name, projectId, platform, version, lang, limits}
kapsula.notify.schedule({at, title, body}) // {id} — exact OS alarm
kapsula.notify.cancel(id?) // no id = cancel all yours
kapsula.notify.list()
kapsula.sound.play('beep' | 'triple' | 'alarm')
kapsula.haptics.vibrate(ms?)
kapsula.storage.get(key) / set(key, value) / remove(key)
Why so small: each method is something a browser tab cannot do. Everything a tab can do (DOM, timers, canvas, Web Audio) is not duplicated. A small API has a second advantage: it fits in a prompt, and the code is written by LLMs — more on that below.
The integration contract for a mini-app is one line:
const K = window.kapsula || null; // null in a plain browser
if (K) await K.notify.schedule({ at: endAt, title: 'Tea is ready' });
The same file works unchanged as a web page on desktop and as an app on the phone.
Android alarms: exact, and three real-device bugs
Alarms are local notifications with allowWhileIdle plus USE_EXACT_ALARM (auto-granted to alarm apps on Android 13+). On a real Samsung (Note20 Ultra, Android 13) delivery is second-exact — 13 ms between scheduled and shown in the logs — including when the app process is dead; the system revives it.
The bugs were more interesting than the happy path:
The silent repeat alarm. The plugin sets
FLAG_ONLY_ALERT_ONCE, and a project's notification id is a fixed slot number. If the previous notification is still sitting in the shade, the next one with the same id counts as an update — and updates don't ring. The vibration I did feel turned out to be Samsung's NotificationReminder, not my notification. Fix:removeDeliveredNotificationswith the same id right before scheduling.Android remembers deleted channels. I changed a channel's sound, deleted the channel, recreated it with the same id — and got the old sound. Channel settings survive deletion. So channel ids carry a version suffix (
kapsula.<project>.v2) that bumps when the sound changes; old channels are left alone because scheduled notifications may still point at them.Battery optimization. The classic OEM problem: an "optimized" app's alarms can drift or drop. Kapsula shows an amber hint card linking to the system screen. Samsung detail: the app is only visible in that list after switching the filter to "All" — that sentence had to go into the hint text.
iOS: the sound lives on the notification
No channels on iOS — the sound is attached per notification (sound: 'kapsula_alarm.wav', file in the bundle), trigger is calendar-based. Honest note: the simulator doesn't play notification sounds, so the first audible test happened on TestFlight on a real iPhone.
Non-code surprise: answering "yes" to unrestricted web access in App Store Connect (URL projects can load anything) sets the age rating to 16+ automatically. Google Play's IARC does the same and lands on 18+. Fine for this audience, but know it going in.
The state rule that AI-generated code always violates
The most common bug in LLM-written mini-apps: state in JS variables and relative time (setTimeout(fn, 3*60*1000)). The host may recreate the sandbox at any moment — for example when the user taps the notification and the project reopens. Everything in memory is gone, and a "3 minute" timer restarts from zero.
The rule is three lines:
- State lives in
kapsula.storageonly — and the UI renders from it. - Time is absolute: store
endAt, never "seconds left". -
cancelbefore everyschedule.
The tea-timer example is 60 lines and shows all three: https://kapsula.app/gallery
Teaching the model: a prompt instead of an SDK
Since the code is written by ChatGPT and Claude rather than humans, the most important artifact isn't the app — it's the public API reference and a prompt. https://kapsula.app/prompt tells the model the sandbox rules (no localStorage, no external fetch, inline everything) and the bridge methods; the output works in a desktop browser and in the app. The bridge client is open source (kapsula-client on npm / github.com/Fedorov191/kapsula-client) — not because anyone npm-installs it (the shell injects it), but so the types and docs have a stable address that crawlers and models can cite.
What's deliberately missing
No network out of the sandbox (yet), no background scripts without UI (Android WorkManager's ≥15-minute granularity and iOS's "whenever the system feels like it" can't honestly be presented as parity), no custom sounds (see channel gotcha above), no cloud. Next up: a deep link + share-sheet entry so chat-to-phone takes ten seconds, a dev panel surfacing JS errors from the sandbox, and kapsula.fetch with per-project permission.
Kapsula is free (one own project + demo), on Google Play and the App Store — links at https://kapsula.app. I'm the author; questions about the sandbox or the alarm plumbing welcome. Reports from Xiaomi/Huawei devices especially — Samsung is tested live, the rest of the OEM zoo is only as good as your bug reports.

Top comments (0)