Skip to content

~5 min readgrounded in apps/worker/src/{push,events,relay,visit,messages}.ts · apps/web/app/api/{push,events}/route.ts · apps/web/components/chat/platform.ts · docs/ENV.md

Notifications & Web Push·

A tiny that only speaks when the chat tab is open cannot run a morning job, tell you someone is on your page, or hand you a device's late reply. Four things carry news out of the deployment; they are layered, and every one of them is yours to run.

1 · The event ring·

Every subsystem that does something on the person's behalf writes one row to the per-user event bus: POST /events {userId, kind, detail?} in the worker, capped at 200 events per user (oldest pruned on write). The app reads it as GET /api/events?sinceId=N — the signed-in user's activity stream, which powers the activity HUD and is also what the agent is shown at the start of a turn ("what happened since you last looked").

Kinds you will see, and who writes them:

kind Written by
job_result · job_error the scheduler, one per run — see Scheduling jobs
device_result the relay, when a device answers after the requester stopped waiting
tiny_visit the visit beacon — someone opened your tiny's page
dm a message from another user (/messages)
telegram · telegram_button an inbound Telegram message or button press handled by your tiny
pay_received · pay_earned · pay_refunded · pay_withdrawn · deposit money events — only when PAYMENTS_ENABLED=true

The ring is the ground truth; pushes are how it gets attention.

2 · Web Push·

apps/worker/src/push.ts is a complete Web Push sender in WebCrypto — VAPID (ES256 JWT) plus RFC 8291 aes128gcm payload encryption, no dependencies, edge-safe. Subscriptions live in D1 (push_subscriptions).

Endpoint Auth Purpose
GET /push/key public the VAPID public key — single source of truth so the page that subscribes and the worker that signs can never drift
POST /push/subscribe {userId, endpoint, keys} internal store a browser subscription
DELETE /push/subscribe {userId, endpoint} internal remove one
POST /push/send {userId, title?, body?, url?} internal send to every subscription the user has

The app proxies these as GET / POST / DELETE /api/push with the session cookie; the worker holds the key pair.

Keys. Generate once and store as worker secrets:

npx web-push generate-vapid-keys
cd apps/worker
wrangler secret put VAPID_PUBLIC_KEY
wrangler secret put VAPID_PRIVATE_KEY
wrangler secret put VAPID_SUBJECT   # optional: mailto:you@example.com

VAPID_SUBJECT is the contact a push service uses to reach you about a misbehaving subscription. When unset, the worker signs with mailto:admin@<APP_URL host> — a domain you own, on purpose: the original platform once signed with a lapsed domain that now resolves to a stranger's site.

In the browser (components/chat/platform.ts): the page registers /sw.js, asks for permission, fetches /api/push for the key, calls pushManager.subscribe({userVisibleOnly: true, applicationServerKey}) and posts the result. A leftover subscription made with a different VAPID key makes subscribe() throw InvalidStateError — the code drops it and re-subscribes, so rotating keys does not strand users. Every failure path resolves to {ok, reason} rather than rejecting, so the "enable notifications" tap never spins forever. The service worker is not registered in local dev (cache-first /_next/static/ plus un-hashed dev chunks serves stale modules).

Payloads are {title, body, data: {url}}; the service worker's push handler shows them and notificationclick opens url. If a subscription's keys are unusable the worker falls back to a payload-less push and the worker shows a generic notification.

Check that apps/web/public/sw.js is in your tree

platform.ts registers /sw.js, and the worker's push payloads are written for its push handler. Confirm the file is present in apps/web/public/ before relying on browser notifications — if it is missing, registration fails, subscribe() never runs, and Web Push is silently off while the event ring and phone relay keep working.

3 · Phones ride the same call·

sendPushToUser does two things: it sends Web Push to every browser subscription and drops a {type: 'notify'} envelope into the device relay for every device that is currently heartbeating. The native apps and tiny-vercel daemons poll the relay every few seconds and banner it — so one call reaches laptop tabs and phones alike, with no second notification system. Devices explains the relay.

Who calls it: the scheduler (a ✅ or ❌ per job run, and the sentence for an abandoned one-shot), the relay (a device's late reply), the visit beacon (throttled), direct messages, and the money events.

4 · Telegram·

Telegram is the channel that works when the person has nothing of yours open. POST /api/telegram stores a per-user bot token from @BotFather in D1; the worker's cron polls getUpdates for every enabled bot each minute and runs each inbound message through the chosen tiny via the job-run pipeline, replying with sendMessage.

  • Pairing. While the bot's chat allowlist is empty it is in pairing mode: it answers any chat with that chat's id and instructions. The owner sends /start, reads the id, and confirms it in tiny chat (telegram tool, allow_chat). Nothing else is answered until then.
  • Flood control. At most 5 messages per bot per poll; the update offset is compare-and-swapped like the scheduler's last_fired_at, so overlapping cron runs never process a message twice.
  • Outbound. The agent's use_telegram tool sends messages from a job or a turn, which is what makes "every morning, check X and message me on Telegram" a single sentence.