A per-device message you set from the dashboard (or raw REST, or MCP) that appears on the iOS Live Activity lock-screen pill / Dynamic Island, and updates in realtime — instantly while the app is foregrounded, and via push while it's backgrounded.
TL;DR
| Set it from | Dashboard device iOS App tab → Live Activity message; POST /rest/v1/device_live_activity_message; MCP set_live_activity_message. |
| Stored in | public.device_live_activity_message (one row per device; message text). |
| Shown on | The Live Activity pill — BusymateActivityAttributes.ContentState.note. Only visible while the device is capturing (mode vpn/pac). |
| Foreground delivery | A broadcast trigger fires live-activity-message on device:<uuid>; iOS LiveActivityController applies it within ms. |
| Background delivery | A pg_net trigger calls push-notify with an APNs liveactivity content-state; iOS updates the pill with no app wake. |
| Clear it | Set message to "" — the pill's note disappears. |
Why two delivery paths
A Live Activity can be updated two ways: by the app while it's running (Activity.update), or by an APNs liveactivity push that iOS applies even when the app is suspended. We want the message to land whatever the device is doing, so a single write to device_live_activity_message.message fans out through two DB triggers — which means any writer (the dashboard, a raw PostgREST PATCH, or the MCP tool) gets the full realtime behaviour with no client-side push code:
- Foreground —
broadcast_live_activity_message→ Realtimelive-activity-messageondevice:<uuid>→ iOSLiveActivityController.setMessage. - Background —
push_live_activity_message(pg_net) →push-notify{ push_type: liveactivity }→ APNs → the system Live Activity (no app wake).
The pieces
Table + triggers (supabase/migrations/…_device_live_activity_message.sql)
device_live_activity_message(device_uuid PK, message, updated_at, updated_by). RLS: humans read/write any row; a device JWT reads its own row only (it consumes, never writes).broadcast_live_activity_message()— clone ofbroadcast_settings_device; emitslive-activity-messageondevice:<uuid>.push_live_activity_message()— readsdevice_statusfor the currentmode(online && source∈{vpn,pac}elseoff), thennet.http_postspush-notifywithaps.content-state = { mode, requestsPerSec: 0, note: message||null }.- Trigger auth: there's no Vault, so the function URL + service key live in a service-role-only
live_activity_push_configrow, seeded bypush-notifyon invocation viaset_live_activity_push_config— the same convention the snapshots cron uses.
iOS (LiveActivityController.swift, RealtimeSubscriber.swift)
The pill's note was always rendered but never set, and every local state-build (VPN toggle, presence flip) passed note: nil. The fix is a stored currentMessage merged into every ContentState the controller hands to ActivityKit, so a local update can't wipe the operator's message:
RealtimeSubscriberhandles thelive-activity-messagebroadcast →LiveActivityController.setMessage.fetchMessage(device-JWT read) runs onbootstrap()+resyncFromSystem()so a freshly-minted Activity — or a foreground after a background APNs message — shows the latest value instead of blanking it.- Background pushes update the system Activity directly; the next foreground resync re-reads the message and re-applies it.
Dashboard (DeviceIosAppTab.tsx)
A Live Activity message row (input + Send / Clear) under Connection type. It only writes the table row (the triggers do the fan-out — unlike the sheet rows, which dual-send a broadcast + push by hand), and reflects the current value in realtime by re-reading on the live-activity-message broadcast (so two open tabs + the device stay in sync, no polling).
MCP (set_live_activity_message)
set_live_activity_message(device_uuid|deviceName, message) upserts the row via the service client; message:"" clears. Mirrored in the /mcp, /ws, and /rest explorers, and covered by npm run test:e2e:mcp.
Notes & edge cases
- Survives a VPN toggle — the merge re-injects
currentMessageon every rebuild (this is the regression the iOS change exists to prevent). - Idle device — if the device isn't capturing there's no pill; the message is stored and shows the moment capture starts (or via the next push).
- Multiple Live Activity tokens (e.g. a TestFlight + production install) all get the push —
push-notifyfans to everykind='liveactivity'token for the device.