push-notifications
SKILL.md
Push Notifications — Expert Decisions
Expert decision frameworks for notification choices. Claude knows UNUserNotificationCenter and APNs — this skill provides judgment calls for permission timing, delivery strategies, and architecture trade-offs.
Decision Trees
Permission Request Timing
When should you ask for notification permission?
├─ User explicitly wants notifications
│ └─ After user taps "Enable Notifications" button
│ Highest acceptance rate (70-80%)
│
├─ After demonstrating value
│ └─ After user completes key action
│ "Get notified when your order ships?"
│ Context-specific, 50-60% acceptance
│
├─ First meaningful moment
│ └─ After onboarding, before home screen
│ Explain why, 30-40% acceptance
│
└─ On app launch
└─ AVOID — lowest acceptance (15-20%)
No context, feels intrusive
The trap: Requesting permission on first launch. Users deny reflexively. Wait for a moment when notifications clearly add value.
Silent vs Visible Notification
What's the notification purpose?
├─ Background data sync
│ └─ Silent notification (content-available: 1)
│ No user interruption, wakes app
│
├─ User needs to know immediately
│ └─ Visible alert
│ Messages, time-sensitive info
│
├─ Informational, not urgent
│ └─ Badge + silent
│ User sees count, checks when ready
│
└─ Needs user action
└─ Visible with actions
Reply, accept/decline buttons
Notification Extension Strategy
Do you need to modify notifications?
├─ Download images/media
│ └─ Notification Service Extension
│ mutable-content: 1 in payload
│
├─ Decrypt end-to-end encrypted content
│ └─ Notification Service Extension
│ Required for E2EE messaging
│
├─ Custom notification UI
│ └─ Notification Content Extension
│ Long-press/3D Touch custom view
│
└─ Standard text/badge
└─ No extension needed
Less complexity, faster delivery
Token Management
How should you handle device tokens?
├─ Single device per user
│ └─ Replace token on registration
│ Simple, most apps need this
│
├─ Multiple devices per user
│ └─ Register all tokens
│ Send to all active devices
│
├─ Token changed (reinstall/restore)
│ └─ Deduplicate on server
│ Same device, new token
│
└─ User logged out
└─ Deregister token from user
Prevents notifications to wrong user
NEVER Do
Permission Handling
NEVER request permission without context:
// ❌ First thing on app launch — user denies
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
return true
}
// ✅ After user action that demonstrates value
func userTappedEnableNotifications() {
showPrePermissionExplanation {
Task {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted == true {
await MainActor.run { registerForRemoteNotifications() }
}
}
}
}
NEVER ignore denied permission:
// ❌ Keeps trying, annoys user
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
if settings.authorizationStatus == .denied {
// Ask again! <- User already said no
requestPermission()
}
}
}
// ✅ Respect denial, offer settings path
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .denied:
showSettingsPrompt() // "Enable in Settings to receive..."
case .notDetermined:
showPrePermissionScreen()
case .authorized, .provisional, .ephemeral:
ensureRegistered()
@unknown default:
break
}
}
}
Token Handling
NEVER cache device tokens long-term in app:
// ❌ Token may change without app knowing
class TokenManager {
static var cachedToken: String? // Stale after reinstall!
func getToken() -> String? {
return Self.cachedToken // May be invalid
}
}
// ✅ Always use fresh token from registration callback
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.hexString
// Send to server immediately — this is the source of truth
Task {
await sendTokenToServer(token)
}
}
NEVER assume token format:
// ❌ Token format is not guaranteed
let tokenString = String(data: deviceToken, encoding: .utf8) // Returns nil!
// ✅ Convert bytes to hex
extension Data {
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}
let tokenString = deviceToken.hexString
Silent Notifications
NEVER rely on silent notifications for time-critical delivery:
// ❌ Silent notifications are low priority
// Server sends: {"aps": {"content-available": 1}}
// Expecting: Immediate delivery
// Reality: iOS may delay minutes/hours or drop entirely
// ✅ Use visible notification for time-critical content
// Or use silent for prefetch, visible for alert
{
"aps": {
"alert": {"title": "New Message", "body": "..."},
"content-available": 1 // Also prefetch in background
}
}
NEVER do heavy work in silent notification handler:
// ❌ System will kill your app
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
await downloadLargeFiles() // Takes too long!
await processAllData() // iOS terminates app
return .newData
}
// ✅ Quick fetch, defer heavy processing
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
// 30 seconds max — fetch metadata only
do {
let hasNew = try await checkForNewContent()
if hasNew {
scheduleBackgroundProcessing() // BGProcessingTask
}
return hasNew ? .newData : .noData
} catch {
return .failed
}
}
Notification Service Extension
NEVER forget expiration handler:
// ❌ System shows unmodified notification
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// Start async work...
downloadImage { image in
// Never called if timeout!
contentHandler(modifiedContent)
}
}
// Missing serviceExtensionTimeWillExpire!
}
// ✅ Always implement expiration handler
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
downloadImage { [weak self] image in
guard let self, let content = self.bestAttemptContent else { return }
if let image { content.attachments = [image] }
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
// Called ~30 seconds — deliver what you have
if let content = bestAttemptContent {
contentHandler?(content)
}
}
}
Essential Patterns
Permission Flow with Pre-Permission
@MainActor
final class NotificationPermissionManager: ObservableObject {
@Published var status: UNAuthorizationStatus = .notDetermined
func checkStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
status = settings.authorizationStatus
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
await checkStatus()
return granted
} catch {
return false
}
}
func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
// Pre-permission screen
struct NotificationPermissionView: View {
@StateObject private var manager = NotificationPermissionManager()
@State private var showSystemPrompt = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "bell.badge")
.font(.system(size: 60))
Text("Stay Updated")
.font(.title)
Text("Get notified about new messages, order updates, and important alerts.")
.multilineTextAlignment(.center)
Button("Enable Notifications") {
Task { await manager.requestPermission() }
}
.buttonStyle(.borderedProminent)
Button("Not Now") { dismiss() }
.foregroundColor(.secondary)
}
.padding()
}
}
Notification Action Handler
@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationHandler()
private let router: DeepLinkRouter
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
// App is in foreground
let userInfo = notification.request.content.userInfo
// Check if we should show banner or handle silently
if shouldShowInForeground(userInfo) {
return [.banner, .sound, .badge]
} else {
handleSilently(userInfo)
return []
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped notification
await handleNotificationTap(userInfo)
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
await handleReply(text: textResponse.userText, userInfo: userInfo)
}
case "MARK_READ_ACTION":
await markAsRead(userInfo)
case UNNotificationDismissActionIdentifier:
// User dismissed
break
default:
await handleCustomAction(response.actionIdentifier, userInfo: userInfo)
}
}
private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) async {
guard let deepLink = userInfo["deep_link"] as? String,
let url = URL(string: deepLink) else { return }
await router.navigate(to: url)
}
}
Rich Notification Service
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
Task {
// Download and attach media
if let mediaURL = request.content.userInfo["media_url"] as? String {
if let attachment = await downloadAttachment(from: mediaURL) {
content.attachments = [attachment]
}
}
// Decrypt if needed
if let encrypted = request.content.userInfo["encrypted_body"] as? String {
content.body = decrypt(encrypted)
}
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let content = bestAttemptContent {
contentHandler?(content)
}
}
private func downloadAttachment(from urlString: String) async -> UNNotificationAttachment? {
guard let url = URL(string: urlString) else { return nil }
do {
let (localURL, response) = try await URLSession.shared.download(from: url)
let fileExtension = (response as? HTTPURLResponse)?
.mimeType.flatMap { mimeToExtension[$0] } ?? "jpg"
let destURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.moveItem(at: localURL, to: destURL)
return try UNNotificationAttachment(identifier: "media", url: destURL)
} catch {
return nil
}
}
}
Quick Reference
Payload Structure
| Field | Purpose | Value |
|---|---|---|
| alert | Visible notification | {title, subtitle, body} |
| badge | App icon badge | Number |
| sound | Notification sound | "default" or filename |
| content-available | Silent/background | 1 |
| mutable-content | Service extension | 1 |
| category | Action buttons | Category identifier |
| thread-id | Notification grouping | Thread identifier |
Permission States
| Status | Meaning | Action |
|---|---|---|
| notDetermined | Never asked | Show pre-permission |
| denied | User declined | Show settings prompt |
| authorized | Full access | Register for remote |
| provisional | Quiet delivery | Consider upgrade prompt |
| ephemeral | App clip temporary | Limited time |
Extension Limits
| Extension | Time Limit | Use Case |
|---|---|---|
| Service Extension | ~30 seconds | Download media, decrypt |
| Content Extension | User interaction | Custom UI |
| Background fetch | ~30 seconds | Data refresh |
Red Flags
| Smell | Problem | Fix |
|---|---|---|
| Permission on launch | Low acceptance | Wait for user action |
| Cached device token | May be stale | Always use callback |
| String(data:encoding:) for token | Returns nil | Use hex encoding |
| Silent for time-critical | May be delayed | Use visible notification |
| Heavy work in silent handler | App terminated | Quick fetch, defer work |
| No serviceExtensionTimeWillExpire | Unmodified content shown | Always implement |
| Ignoring denied status | Frustrates user | Offer settings path |
Weekly Installs
17
Repository
kaakati/rails-e…rise-devGitHub Stars
6
First Seen
Jan 25, 2026
Security Audits
Installed on
opencode14
claude-code13
gemini-cli13
codex12
antigravity11
github-copilot11