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
GitHub Stars
6
First Seen
Jan 25, 2026
Installed on
opencode14
claude-code13
gemini-cli13
codex12
antigravity11
github-copilot11