Notifications
Last verified: 2026-08-08
Reading notifications is FOSS-only and Android-only. The Play Store variant of Kai does not declare
BIND_NOTIFICATION_LISTENER_SERVICEand the feature is invisible there — no settings, no tools, no code path. Play Store's notification-access policies restrict the listener to a narrow set of approved use cases (accessibility, smartwatches, replacement notification UIs), which Kai is not.
Kai on the FOSS Android build can read notifications posted by other apps and surface them to the AI, mirroring the SMS feature: per-app opt-in, capped pending queue, heartbeat summary, plus on-demand check_notifications / read_notification / search_notifications tools.
There is no "send" counterpart in v1. Acting on a notification (replying via RemoteInput, dismissing) is out of scope for the first cut and would follow the SMS-send draft pattern when added.
Availability
- FOSS Android build: fully available.
- Play Store Android build: feature is invisible —
BIND_NOTIFICATION_LISTENER_SERVICEis not declared in the Play flavor's merged manifest, the runtime support check returns false, the settings section is hidden, and the notification tools are never registered. - iOS / desktop / web: unsupported. iOS does not allow third-party apps to read system notifications at all; desktop and web have no equivalent surface. No-op stubs.
The FOSS gate is purely manifest-based: the foss product flavor contributes androidApp/src/foss/AndroidManifest.xml declaring the listener service with BIND_NOTIFICATION_LISTENER_SERVICE, while the playStore flavor does not. At runtime the app queries PackageManager.getPackageInfo(…, GET_SERVICES) (or checks the merged-manifest service registration) to decide whether to show the feature.
Scope
- Read: list / read / search notifications posted to the system tray since the listener was bound.
- Per-app filtering is delegated to the OS. System Notification Access already exposes an "Apps" picker per listener — if the user unchecks an app there,
onNotificationPostedis never fired for that package. We don't duplicate that UI in Kai; the in-app toggle is just a master switch for the whole feature. - Visible notifications only. Ongoing and foreground-service notifications (media controls, downloads, navigation) are filtered out via
FLAG_ONGOING_EVENTandFLAG_FOREGROUND_SERVICE— they are sticky UI affordances, not events. Posts with a blank title and blank text are also dropped at capture. - No reply, no dismiss, no action invocation in v1. The listener is read-only.
- Secret visibility skipped. Notifications posted with
Notification.VISIBILITY_SECRETare skipped. There is no separate "sensitive / lockscreen-redacted" flag on stored records in v1 — content is captured as posted for everything else that passes the filters.
Opt-in flow
Notification access is granted via system settings, not a runtime permission dialog — there is no requestPermissions path for BIND_NOTIFICATION_LISTENER_SERVICE.
- In Settings → Agent → Notifications → "Read notifications", the user flips the toggle on. The toggle records user intent and stays on even if access is not yet granted.
- The app deep-links to Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS (or the per-component variant on API 30+) and instructs the user to enable Kai in the list.
- On return (and whenever the settings screen becomes visible), the app re-reads
NotificationManager.isNotificationListenerAccessGranted(…)(API 27+) orSettings.Secure.getString("enabled_notification_listeners"). Access state is shown separately ("Listener active" / inactive, plus an open-access control when intent is on but access is missing). The master toggle does not auto-reset when access is denied. - Once granted, Android binds
KaiNotificationListenerService. From then on everyonNotificationPostedcallback that passes the visibility filters writes a record into the pending queue. Until access is granted, posts are not delivered to the listener. The user can refine which apps Kai sees from the same system Notification Access screen — the "Apps" picker per listener is the source of truth. - On the next heartbeat, the queue snapshot is included in the prompt under
## New Notifications. After the heartbeat run, exactly that snapshot is removed from the queue — notifications that arrived during the call survive to the next heartbeat.
If the user later revokes notification access from system settings, onListenerDisconnected fires, access reads as false, and the read tools stop appearing in the AI's available-tools list until access is re-granted (the intent toggle can remain on).
Per-app filtering
Per-app filtering is the OS's job. Android's system Notification Access screen, when opened on a specific listener, exposes an "Apps" picker that lets the user toggle which apps the listener can read. Kai's settings card includes a "Manage apps" button that deep-links straight there.
This was a deliberate simplification — earlier iterations of this feature shipped a Kai-side "Ignored apps" list, but the OS-level picker covers the same ground without duplicating UI or maintaining a parallel allowlist. A small set of packages is still hard-blocked at the listener callback (Kai itself, system UI) to avoid feedback loops, but everything else flows through whatever the system has approved.
No polling interval
Unlike SMS and email, there is no pollIntervalMinutes. Notifications are push: the listener service receives onNotificationPosted callbacks the moment a notification fires, and writes synchronously to the store. The scheduler does not need a checkNewNotifications hook.
The heartbeat still drives the AI summarisation cadence — the pending queue accumulates between heartbeats and gets flushed when the heartbeat runs.
Retention
The pending queue and the broader notification store are both bounded:
- Pending queue capacity 100 (FIFO). Older notifications are dropped if the queue fills before a heartbeat consumes it. Identical to SMS.
- Per-app age cap 24h. After each successful heartbeat consumption, a sweep drops records older than 24 hours from the broader store. There is no separate retention sweep on listener bind.
- Per-app record cap 50. Prevents one chatty app (e.g. a group chat) from monopolising the store. Oldest-first eviction.
Records are persisted in the encrypted app settings store alongside email/SMS pending so they survive process death.
Captured fields
| Field | Source | Notes |
|---|---|---|
id |
StatusBarNotification.key |
Stable across post/update for the same notification. Used as the read/search lookup key. |
package_name |
StatusBarNotification.packageName |
Used for ignore-list matching. |
app_label |
PackageManager.getApplicationLabel |
Best-effort; falls back to package name on lookup failure. |
title |
extras["android.title"] |
Trimmed. |
text |
extras["android.bigText"] ?: extras["android.text"] |
Big text preferred when present. |
subtext |
extras["android.subText"] |
Optional. |
posted_at |
StatusBarNotification.postTime |
Epoch ms. |
is_ongoing |
Notification.flags & FLAG_ONGOING_EVENT |
Used to filter sticky notifications at capture time. |
category |
Notification.category |
e.g. msg, email, alarm. |
preview |
First 200 chars of text |
Shown in check_notifications and the heartbeat prompt. |
Text is taken from extras["android.bigText"] when present, otherwise extras["android.text"]. There is no separate MessagingStyle / android.messages parse in v1 — group-chat style notifications appear as whatever single title/text the system put on the status-bar notification.
AI Tools
Registered in getAvailableTools() on Android only, gated on isNotificationsSupported && isNotificationsEnabled && hasListenerAccess:
| Tool | Purpose |
|---|---|
check_notifications |
List notifications currently in the heartbeat pending queue. Returns id, package_name, app_label, title, posted_at, preview for each. |
read_notification |
Fetch the full body of a single notification by id (the StatusBarNotification.key). |
search_notifications |
Full-text search over app_label + title + text, newest first, capped at 20. Optional package_name filter. |
The pattern intentionally mirrors the SMS read triplet so the AI can transfer mental model: "check for new things, read a specific one, or search by text."
All three also carry a display definition, so chat shows their names ("Check Notifications") rather than raw tool ids while they run. As with SMS, the definitions are marked non-toggleable — the "Read notifications" master switch is the only control — so they do not appear in the Tools tab.
Heartbeat surface
The heartbeat prompt builder gains a ## New Notifications section, formatted parallel to ## New Emails / ## New SMS:
## New Notifications
These notifications arrived since the last heartbeat. Summarise briefly; only flag items that genuinely need attention.
- **WhatsApp** — Alice (id: 0|com.whatsapp|...): Hey, are we still on for tonight?
- **Gmail** (id: 0|com.google.android.gm|...): New message from boss@…
Same lifecycle as the SMS pending queue: the snapshot is taken before the heartbeat, only that snapshot is removed afterward, anything that arrived during the call survives.
Notifications
No notifications-specific push notification. New notifications surface via the existing heartbeat notification path: if the heartbeat produces a non-HEARTBEAT_OK response while Kai is backgrounded, the standard heartbeat notification fires and deep-links into the heartbeat conversation.
Settings UI
The Notifications section appears in Settings → Agent only when isNotificationsSupported is true (FOSS build). One card with:
- Read notifications toggle — records intent and deep-links to system notification-access settings when turned on; remains on even if access is still missing.
- "Open notification access" (or equivalent access-required row) — shown when the toggle is on but access has not been granted (or was revoked).
- Listener status — "Listener active" / "Listener inactive — check notification access".
- "Manage apps" button — deep-links to the same system Notification Access screen so the user can adjust which apps Kai can read.
- Queued count + Clear queue — number of notifications sitting in the pending queue waiting for the next heartbeat, with a button to flush them on demand.
There is no poll interval slider — the listener is push-driven.
Key Files
| File | Purpose |
|---|---|
androidApp/src/foss/AndroidManifest.xml |
Declares BIND_NOTIFICATION_LISTENER_SERVICE and registers KaiNotificationListenerService in the FOSS flavor only |
composeApp/src/commonMain/.../data/NotificationModels.kt |
NotificationRecord, NotificationSyncState data classes |
composeApp/src/commonMain/.../data/NotificationStore.kt |
Pending queue + broader store + retention sweeps |
composeApp/src/commonMain/.../data/SettingsJson.kt |
Shared settings-backed JSON persistence: decode-or-default, encode-and-write, locked read-modify-write |
composeApp/src/commonMain/.../notifications/NotificationReader.kt |
Expect interface for getById, search, currentRecords |
composeApp/src/androidMain/.../notifications/NotificationReader.android.kt |
Reads from the in-memory + persisted listener store |
composeApp/src/androidMain/.../notifications/KaiNotificationListenerService.kt |
NotificationListenerService subclass; visibility/hard-block filter + write to NotificationStore |
composeApp/src/commonMain/.../tools/NotificationTools.kt |
check_notifications, read_notification, search_notifications tool definitions |
composeApp/src/commonMain/.../tools/NotificationListenerController.kt |
Expect interface for "is access granted" + "open settings" |
composeApp/src/androidMain/.../tools/NotificationListenerController.android.kt |
NotificationManager.isNotificationListenerAccessGranted + Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS deep-link |
composeApp/src/androidMain/.../Platform.android.kt |
isNotificationsSupported gate + conditional tool registration |
composeApp/src/commonMain/.../data/HeartbeatPromptBuilder.kt |
## New Notifications section renderer |
composeApp/src/commonMain/.../data/TaskScheduler.kt |
Heartbeat snapshot/remove lifecycle for the pending queue (no poll hook — listener is push) |
composeApp/src/commonMain/.../ui/settings/HeartbeatSection.kt |
NotificationsSection Compose UI with toggle + Manage apps deep-link |
Future scope (not v1)
reply_notificationtool. Inspect the capturedNotification.actionsfor aRemoteInput-bearing reply action; if present, expose a draft tool that mirrorsreply_sms(banner-gated send). Useful for messaging apps.dismiss_notificationtool. CallNotificationListenerService.cancelNotification(key). Low-friction so could ship behind a separate "Allow dismissing" toggle.- OTP redaction. Auto-detect 4–8 digit OTP-style codes in capture and elide them from the heartbeat prompt by default; let the AI request the full body via
read_notificationonly when it's clearly a non-sensitive context. - Per-channel filtering. Within an allowed app, let the user pick which
NotificationChannelIDs to capture (e.g. allow Slack mentions, drop Slack reactions).