Mobile Push Notifications skill for Codex
Pushes that arrive, tokens that stay clean. Codex picks the payload type each OS will actually deliver (alert, background, data, mutable content), sends through APNs token auth or the FCM v1 API, and stores tokens with their environment so debug tokens never hit production. It prunes tokens on Unregistered errors, builds Android channels and the Android 13 permission flow, and times the iOS prompt behind an in-app explanation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mobile-push-notifications Installs to ~/.agents/skills/mobile-push-notifications/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mobile-push-notifications The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mobile-push-notifications Installs to .agents/skills/mobile-push-notifications/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mobile-push-notifications.zipunzip -o mobile-push-notifications.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mobile-push-notifications PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mobile-push-notifications, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements push notifications for iOS and Android end to end - APNs token auth and headers, FCM HTTP v1 sends, device token lifecycle, alert versus background versus data payloads, Android notification channels and the POST_NOTIFICATIONS permission, iOS provisional authorization, notification service extensions, and permission prompts timed for opt-in. Use when notifications do not arrive, when wiring APNs or Firebase Cloud Messaging, designing payloads, migrating off the legacy FCM API, or improving push opt-in rates.
Use it when
- Wiring APNs, Firebase Cloud Messaging or Expo notifications
- Notifications arrive on one platform, build type or app state but not another
- Designing payloads for alerts, silent refresh or rich media
- Opt-in rate is low or the permission prompt fires at launch
Use something else when
- You are updating a Live Activity or widget by push: use ios-widgets
- The question is where a notification tap should navigate: use mobile-deep-links
How to check the result
Send to one device per platform in both debug and release environments across foreground, background and terminated states, then uninstall and confirm the backend deletes the token on the error response.
The mistake it prevents
Storing tokens without their APNs environment, so sandbox tokens from debug builds are sent to production and fail with BadDeviceToken.
Done well means: every token the backend holds belongs to a live install and the right environment, each push uses the payload type the OS will actually deliver, users are asked for permission at a moment that explains the value, and failed sends prune dead tokens instead of retrying forever.
Before changing anything
- Read
AGENTS.md, then find the existing stack: native APNs, Firebase Messaging on both platforms, Expo (expo-notifications), OneSignal or another provider. Do not mix two providers for the same platform. - Locate token registration on the client and token storage on the server (table, TTL, user and device keys).
- Check entitlements and manifests: iOS
aps-environmententitlement and Push capability; AndroidPOST_NOTIFICATIONSin the manifest and aFirebaseMessagingServicesubclass. - Real sends need network and credentials (
.p8key, service account JSON). Never paste keys into code or chat; read them from the environment or a secret manager. - Sending a test push to real users is outward-facing: confirm the target token or topic with the user first, and default to a single test device.
Pick the payload type
| Goal | iOS (APNs) | Android (FCM) |
|---|---|---|
| Visible alert | apns-push-type: alert, aps.alert | notification block, or data and build it yourself |
| Silent refresh | apns-push-type: background, aps.content-available: 1, priority 5, no alert | data only, normal priority |
| Modify before display (images, decryption) | aps.mutable-content: 1 + Notification Service Extension | data message, build in onMessageReceived |
| Live Activity update | apns-push-type: liveactivity (see ios-widgets) | n/a |
| Calls | PushKit VoIP, must report to CallKit immediately | High-priority data + full-screen intent |
Background pushes are best-effort on both platforms: throttled, dropped under low power, and never delivered on iOS after the user force-quits the app. Never rely on them for correctness; sync on next launch as well.
On Android, an FCM message with a notification block is shown by the system when the app
is in the background and onMessageReceived is not called; with the app in the foreground,
it is. If the app must always control display, send data only.
APNs essentials
- Prefer token-based auth: one
.p8key per team, JWT signed ES256 withkid(key ID) andiss(team ID). Reuse the JWT for up to an hour and refresh it no more than every 20 minutes; regenerating per request triggersTooManyProviderTokenUpdates. - Endpoint
https://api.push.apple.com/3/device/<token>over HTTP/2; development builds useapi.sandbox.push.apple.com. A token from a debug build sent to production returns400 BadDeviceToken; store the environment with every token. - Required headers:
apns-topic(bundle ID),apns-push-type. Useapns-priority: 10for alerts,5for background. Setapns-expirationandapns-collapse-id(max 64 bytes) for replaceable content such as scores. - Payload limit 4 KB (5 KB for VoIP). Send IDs, not documents.
{
"aps": {
"alert": { "title": "Order shipped", "body": "Arrives Thursday" },
"sound": "default",
"thread-id": "order-8812",
"interruption-level": "active",
"mutable-content": 1
},
"orderId": "8812"
}
interruption-level values: passive, active, time-sensitive (needs the Time
Sensitive capability), critical (needs an Apple-granted entitlement).
FCM HTTP v1
The legacy FCM send API is shut down; every send uses
POST https://fcm.googleapis.com/v1/projects/<project-id>/messages:send with an OAuth 2
access token from a service account (Firebase Admin SDK handles this).
{
"message": {
"token": "<registration token>",
"data": { "type": "order_shipped", "orderId": "8812" },
"android": { "priority": "HIGH", "ttl": "3600s", "collapse_key": "order-8812" },
"apns": {
"headers": { "apns-priority": "10", "apns-push-type": "alert" },
"payload": { "aps": { "alert": { "title": "Order shipped", "body": "Arrives Thursday" } } }
}
}
}
datavalues must be strings. Nested JSON goes in as an encoded string.- High priority is for messages that show something to the user; Android deprioritizes apps whose high-priority messages do not result in a notification.
- iOS through FCM still needs the APNs key uploaded in the Firebase console.
- Use topics or conditions for broadcast, and the batch-capable Admin SDK
sendEachfor per-user fan-out; there is no multicast endpoint in v1 itself.
Token lifecycle
- Register on every app launch and send the token to the backend when it changes (iOS
didRegisterForRemoteNotificationsWithDeviceToken, FCMonNewToken/messaging:didReceiveRegistrationToken). - Store
(user_id, device_id, platform, environment, token, app_version, updated_at). One row per device, upserted, so reinstalls do not create duplicates. - Delete the token on APNs
410 Unregisteredor400 BadDeviceToken, and on FCMUNREGISTERED/NOT_FOUND. Keep it on429/5xx; retry with backoff. - On logout, delete the server row (and call
deleteTokenon FCM) so the next user on a shared device does not receive the previous user’s messages. - Expire tokens not refreshed for 60-90 days.
Permission UX
- iOS asks once. Show an in-app explanation first, and request only after the user taps a button tied to a concrete benefit (“Notify me when it ships”).
- Consider provisional authorization (
.provisional) to deliver quietly to Notification Center without a prompt; promote to full alerts after the user engages. - Android 13+ requires the
POST_NOTIFICATIONSruntime permission; after two denials the system stops showing the dialog. CheckshouldShowRequestPermissionRationaleand offer a Settings deep link instead of re-requesting. - Create Android notification channels at startup with user-meaningful names (“Order updates”, “Promotions”); users can mute channels individually, and you cannot raise a channel’s importance after creation.
- Re-check authorization status on every foreground; users change it in Settings.
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted { await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } }
Handling taps
Route the tap through the same deep-link router the app uses for URLs: put a deeplink
string in the payload, parse it in userNotificationCenter(_:didReceive:) or the Android
intent extras, and treat it as untrusted input. See mobile-deep-links for the router.
Gotchas
- Simulator push:
xcrun simctl push booted <bundle-id> payload.apnsworks for local testing but proves nothing about APNs credentials or tokens. - A Notification Service Extension has about 30 seconds and a small memory budget; download thumbnails, not videos, and always call the content handler with the best attempt.
- Badge counts must come from the server’s unread total; incrementing on the device drifts.
- Firebase on iOS swizzles app delegate methods; if
FirebaseAppDelegateProxyEnabledisNO, you must pass the APNs token toMessaging.messaging().apnsTokenyourself. - Marketing pushes need an opt-out and must respect quiet hours and local time zones.
Verification
- Send to one test device per platform and environment (debug and release builds).
- Test app states: foreground, background, terminated, and after force-quit on iOS.
- Revoke permission in Settings and confirm the app detects it on next foreground.
- Uninstall, send, and confirm the backend deletes the token on the error response.
- Check delivery metrics: FCM delivery data in the Firebase console; Apple’s Push Notifications Console for APNs delivery logs.
Output
**Provider:** APNs direct | FCM both platforms | other
**Payloads:** <type, push-type/priority, sample JSON per message kind>
**Token store:** <schema, prune rules>
**Permission flow:** <when asked, pre-prompt copy, fallback>
**Tested:** <device, OS, build env, app state> -> delivered / not delivered What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Mobile Push Notifications"
short_description: "Pushes that arrive, tokens that stay clean"
default_prompt: "$mobile-push-notifications Push notifications work in debug but never arrive on TestFlight builds" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.