npx claudepluginhub charleswiltgen/axiom --plugin axiomThis skill uses the workspace's default tool permissions.
Remote and local notification patterns for iOS. Covers permission flow, APNs registration, token management, payload design, actionable notifications, rich notifications with service extensions, communication notifications, Focus interaction, and Live Activity push transport.
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
Remote and local notification patterns for iOS. Covers permission flow, APNs registration, token management, payload design, actionable notifications, rich notifications with service extensions, communication notifications, Focus interaction, and Live Activity push transport.
Use when you need to:
"How do I set up push notifications?" "When should I ask for notification permission?" "My push notifications aren't arriving" "How do I add buttons to notifications?" "How do I show images in push notifications?" "How do I send push updates to a Live Activity?" "What's the difference between APNs token and FCM token?" "How do I make notifications break through Focus mode?" "My notifications work in development but not production" "How do I handle notification taps to open a specific screen?"
Signs you're making this harder than it needs to be:
content-available: 1 without understanding silent push throttling (~2-3/hour)serviceExtensionTimeWillExpire fallbackapns-priority: 10 for all notifications (drains battery, gets throttled)Before implementing any push notification feature:
aps-environment entitlement// AppDelegate
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // Never cache locally — tokens change
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Simulator cannot register. Log, don't crash.
}
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}
Request when user action makes notification value obvious (e.g., after scheduling a reminder, subscribing to updates). The system prompts only once — bad timing means permanent denial.
Request in context after user understands the value:
func subscribeToUpdates() async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted == true {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}
case .authorized, .provisional:
// Already have permission
break
case .denied:
// Redirect to Settings
promptToOpenSettings()
case .ephemeral:
break
@unknown default:
break
}
}
Notifications appear quietly in Notification Center with Keep/Turn Off buttons. No permission dialog shown to user.
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])
Good for apps where users haven't yet discovered notification value. They see notifications quietly and choose to promote them.
Always check before scheduling or assuming permission:
let settings = await UNUserNotificationCenter.current().notificationSettings()
guard settings.authorizationStatus == .authorized else {
// Handle missing permission
return
}
Redirect to Settings when user has denied:
func promptToOpenSettings() {
// iOS 16+
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
UIApplication.shared.open(url)
} else {
// Fallback: general app settings
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token)
}
registerForRemoteNotifications()api.sandbox.push.apple.com vs api.push.apple.com)What type of notification?
│
├─ Alert (user-visible)
│ ├─ Passive — informational, no sound, appears in history
│ │ interruption-level: "passive"
│ │
│ ├─ Active — default, sound + banner
│ │ interruption-level: "active" (or omit, it's default)
│ │
│ ├─ Time Sensitive — breaks through scheduled summary, not Focus
│ │ interruption-level: "time-sensitive"
│ │ Requires: Time Sensitive Notifications capability
│ │
│ └─ Critical — breaks through Do Not Disturb and mute switch
│ interruption-level: "critical"
│ Requires: Apple entitlement approval (medical, safety, security)
│
├─ Communication (iOS 15+)
│ Shows sender avatar, name, breaks Focus for allowed contacts
│ Requires: INSendMessageIntent + Communication Notifications capability
│ Configured in service extension via content.updating(from: intent)
│
├─ Silent / Background
│ content-available: 1, no alert/sound/badge
│ Throttled to ~2-3 per hour
│ apns-priority: 5 (MUST be 5, not 10)
│ App gets ~30 seconds background execution
│
└─ Live Activity
apns-push-type: liveactivity
apns-topic: {bundleID}.push-type.liveactivity
Updates/starts/ends Live Activities remotely
{
"aps": {
"alert": {
"title": "New Message",
"subtitle": "From Alice",
"body": "Hey, are you free for lunch?"
},
"sound": "default",
"badge": 3
}
}
{
"aps": {
"alert": { "title": "Notification", "body": "With custom sound" },
"sound": "custom-sound.aiff"
}
}
Critical alert (requires Apple entitlement):
{
"aps": {
"alert": { "title": "Emergency", "body": "Critical alert" },
"sound": {
"critical": 1,
"name": "alarm.aiff",
"volume": 0.8
}
}
}
{
"aps": {
"badge": 5
}
}
Set to 0 to remove badge.
{
"aps": {
"alert": {
"loc-key": "NEW_MESSAGE_FORMAT",
"loc-args": ["Alice", "lunch"]
}
}
}
Place custom data outside the aps dictionary:
{
"aps": {
"alert": { "title": "Order Update", "body": "Your order shipped" }
},
"orderId": "12345",
"deepLink": "/orders/12345"
}
{
"aps": {
"alert": { "title": "Breaking News", "body": "..." },
"relevance-score": 0.8,
"thread-id": "news-breaking",
"interruption-level": "time-sensitive"
}
}
relevance-score (0.0–1.0): ranking for notification summary (iOS 15+)thread-id: groups notifications into conversations in Notification Center| Type | Max Size |
|---|---|
| Standard push | 4KB |
| VoIP push | 5KB |
| Live Activity | 4KB |
APNs silently rejects oversized payloads. No error returned to sender.
func registerNotificationCategories() {
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY_ACTION",
title: "Reply",
options: [])
// iOS 15+: actions with icons
let likeIcon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
let likeAction = UNNotificationAction(
identifier: "LIKE_ACTION",
title: "Like",
options: [],
icon: likeIcon)
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE",
actions: [replyAction, likeAction],
intentIdentifiers: [],
options: [.customDismissAction])
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
{
"aps": {
"alert": { "title": "Alice", "body": "Are you free?" },
"category": "MESSAGE"
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
handleReply(text: textResponse.userText, userInfo: userInfo)
}
case "LIKE_ACTION":
handleLike(userInfo: userInfo)
case UNNotificationDefaultActionIdentifier:
// User tapped the notification itself
handleNotificationTap(userInfo: userInfo)
case UNNotificationDismissActionIdentifier:
// User dismissed (requires .customDismissAction on category)
handleDismiss(userInfo: userInfo)
default:
break
}
completionHandler()
}
}
Download and attach images, audio, or video to notifications.
Payload requirement: Must include "mutable-content": 1:
{
"aps": {
"alert": { "title": "Photo", "body": "Alice sent a photo" },
"mutable-content": 1
},
"imageURL": "https://example.com/photo.jpg"
}
Service extension:
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 bestAttemptContent,
let imageURLString = bestAttemptContent.userInfo["imageURL"] as? String,
let imageURL = URL(string: imageURLString) else {
contentHandler(request.content)
return
}
// Download image
let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] url, _, error in
guard let self, let url, error == nil else {
contentHandler(self?.bestAttemptContent ?? request.content)
return
}
// Move to tmp with proper extension
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try? FileManager.default.moveItem(at: url, to: tmpURL)
if let attachment = try? UNNotificationAttachment(identifier: "image",
url: tmpURL,
options: nil) {
bestAttemptContent.attachments = [attachment]
}
contentHandler(bestAttemptContent)
}
task.resume()
}
override func serviceExtensionTimeWillExpire() {
// 30-second window exceeded — deliver what we have
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
Decrypt payload in service extension before display:
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent,
let encryptedBody = bestAttemptContent.userInfo["encryptedBody"] as? String else {
contentHandler(request.content)
return
}
if let decrypted = decrypt(encryptedBody) {
bestAttemptContent.body = decrypted
} else {
bestAttemptContent.body = "(Encrypted message)"
}
contentHandler(bestAttemptContent)
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let bestAttemptContent {
bestAttemptContent.body = "(Encrypted message)"
contentHandler(bestAttemptContent)
}
}
30-second processing window: If didReceive doesn't call contentHandler within ~30 seconds, serviceExtensionTimeWillExpire is called. Always deliver bestAttemptContent as fallback — if neither method calls the handler, the notification vanishes entirely.
Show sender avatar and name. Can break through Focus for allowed contacts.
// In your Notification Service Extension
import Intents
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void) {
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
// 1. Create sender persona
let senderImage = INImage(url: avatarURL) // or INImage(imageData:)
let sender = INPerson(
personHandle: INPersonHandle(value: "alice@example.com", type: .emailAddress),
nameComponents: nil,
displayName: "Alice",
image: senderImage,
contactIdentifier: nil,
customIdentifier: "user-alice-123"
)
// 2. Create message intent
let intent = INSendMessageIntent(
recipients: nil, // nil for 1:1, set for group
outgoingMessageType: .outgoingMessageText,
content: bestAttemptContent.body,
speakableGroupName: nil, // set for group conversations
conversationIdentifier: "conversation-123",
serviceName: nil,
sender: sender,
attachments: nil
)
// 3. Donate interaction
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
// 4. Update content with intent
do {
let updatedContent = try bestAttemptContent.updating(from: intent)
contentHandler(updatedContent)
} catch {
contentHandler(bestAttemptContent)
}
}
Requirements:
mutable-content: 1 in payloadFocus breakthrough: Communication notifications from contacts the user has allowed in Focus settings will break through. Use sparingly — overuse erodes trust.
Without this delegate method, notifications received while the app is in foreground are silently dropped:
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
// Show notification even when app is in foreground
completionHandler([.banner, .sound, .badge])
}
}
Set UNUserNotificationCenter.current().delegate = self in didFinishLaunchingWithOptions — before the app finishes launching.
Push updates to Live Activities remotely via APNs.
let activity = try Activity<OrderAttributes>.request(
attributes: attributes,
content: initialContent,
pushType: .token
)
Task {
for await pushToken in activity.pushTokenUpdates {
let pushTokenString = pushToken.reduce("") {
$0 + String(format: "%02x", $1)
}
try await sendPushToken(pushTokenString: pushTokenString)
}
}
| Header | Value |
|---|---|
| apns-push-type | liveactivity |
| apns-topic | {bundleID}.push-type.liveactivity |
| apns-priority | 5 (routine) or 10 (time-sensitive) |
{
"aps": {
"timestamp": 1234567890,
"event": "update",
"content-state": {
"currentStep": "outForDelivery",
"estimatedArrival": "2:30 PM"
},
"stale-date": 1234571490,
"dismissal-date": 1234575090,
"relevance-score": 75.0
}
}
| Event | Purpose |
|---|---|
update | Update content-state |
start | Start a new Live Activity remotely (iOS 17.2+) |
end | End the activity |
content-state must match ActivityAttributes.ContentState exactly — no custom JSON encoding strategies, property names must be identicaltimestamp is required — APNs uses it to discard stale updatesstale-date shows a visual indicator that data is outdateddismissal-date controls when an ended activity disappears from Lock Screenrelevance-score orders multiple active Live Activitiesapns-priority: 10 gets throttledNSSupportsLiveActivitiesFrequentUpdates to Info.plist for high-frequency apps (sports, navigation)For ActivityKit UI, attributes, and Dynamic Island layout, see axiom-extensions-widgets.
Channel-based delivery for large audiences (sports scores, flight status, breaking news).
let activity = try Activity<ScoreAttributes>.request(
attributes: attributes,
content: initialContent,
pushType: .channel(channelId)
)
POST /4/broadcasts/apps/{TOPIC}
If using Firebase Cloud Messaging as your push provider, watch for these gotchas:
FCM swizzles UNUserNotificationCenterDelegate methods and didRegisterForRemoteNotifications by default. If you have custom delegate handling, they conflict.
Fix: Set in Info.plist:
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>
Then manually pass the APNs token to FCM:
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
FCM token ≠ APNs device token. They are completely different. Send the correct one to the correct backend.
Upload your .p8 APNs authentication key to Firebase Console → Project Settings → Cloud Messaging. Without this, development builds work (FCM uses sandbox automatically) but production builds silently fail.
FCM's content_available maps to APNs content-available, but FCM may add extra fields to the payload. Monitor total size to avoid exceeding the 4KB limit.
Wrong:
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
return true
}
Right:
// After user taps "Subscribe to updates" or completes onboarding
func subscribeButtonTapped() async {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])
if granted == true {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}
}
Why it matters: The system only shows the permission dialog once. If the user hasn't seen value yet, they tap "Don't Allow" reflexively. ~60% of users who deny never re-enable in Settings. You get one shot.
Wrong:
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
UserDefaults.standard.set(tokenString, forKey: "pushToken") // Stale after restore
}
Right:
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(tokenString) // Fresh every launch
}
Why it matters: Tokens change after backup restore, device migration, reinstall, and sometimes after OS updates. A stale cached token means your server sends to a token APNs no longer recognizes — notifications silently vanish.
Wrong:
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// Download large image, no timeout handling
downloadImage(from: url) { image in
// If this takes >30 seconds, notification vanishes entirely
contentHandler(modifiedContent)
}
}
// serviceExtensionTimeWillExpire not implemented
Right:
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let bestAttemptContent {
// Deliver whatever we have — text without image is better than nothing
contentHandler(bestAttemptContent)
}
}
Why it matters: The service extension has a ~30 second window. If neither didReceive nor serviceExtensionTimeWillExpire calls the content handler, the notification disappears completely. Users never see it.
Wrong:
{
"aps": {
"alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
"interruption-level": "time-sensitive"
}
}
Right:
{
"aps": {
"alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
"interruption-level": "passive"
}
}
Why it matters: iOS shows users which apps overuse Time Sensitive. Users who feel interrupted will disable ALL notifications from your app — not just Time Sensitive ones. Reserve it for genuinely time-bound events (delivery arriving, meeting starting, security alerts). Apple can also revoke the capability.
Context: PM needs push notifications working for a demo.
Pressure: "Just ask for permission at launch, we'll fix it later."
Reality: The system only prompts once. If the user denies, you need them to manually enable in Settings. ~60% of users never do. "Fix it later" means permanently lower opt-in rates.
Correct action:
Push-back template: "Permission timing directly affects our opt-in rate. A 2-hour investment now prevents a 30% lower notification reach permanently. Let me implement the contextual prompt — it's the same amount of code, just in the right place."
Context: App Store build doesn't receive push notifications.
Pressure: "Something is wrong with APNs, let's file a radar."
Reality: 95% of the time it's a sandbox/production token mismatch. Dev builds use api.sandbox.push.apple.com, production uses api.push.apple.com. Tokens are different per environment. The same token sent to the wrong endpoint silently fails.
Correct action:
Push-back template: "Before filing a radar, let me verify our token/environment configuration. This is the number one cause of 'works in dev, not production' and takes 5 minutes to check."
Context: Product wants maximum notification visibility.
Pressure: "Users need to see our notifications immediately."
Reality: iOS shows users which apps overuse Time Sensitive. Users who feel interrupted will disable ALL notifications from your app — not just Time Sensitive. Apple can also revoke the entitlement for abuse.
Correct action:
passive for informational, active (default) for normal engagement, time-sensitive only for truly time-bound eventsPush-back template: "Overusing Time Sensitive will cause users to disable our notifications entirely. Let's classify by urgency — most notifications should be active, with time-sensitive reserved for genuinely time-bound events like delivery arrivals or expiring offers."
Before shipping push notifications:
Entitlements:
Permissions:
Token Management:
Payload:
Service Extension (if applicable):
Testing:
WWDC: 2021-10091, 2023-10025, 2023-10185, 2024-10069
Docs: /usernotifications, /usernotifications/unusernotificationcenter, /activitykit
Skills: axiom-push-notifications-ref, axiom-push-notifications-diag, axiom-extensions-widgets, axiom-background-processing