YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
General▾
Interface▾
Observability▾
Push Setup
The prompt appears, a token comes back, and the server logs a send. Nothing arrives on the phone.
Push is one client API,
usePushNotification(), and two senders on the server: APNs for iOS, FCM for Android and the web. A token sent without its sender's credential is skipped with one log line, so prepare every row that applies to you.Words used on this page
TermDescription
FCM
Firebase Cloud Messaging. Akan sends to Android apps and browsers through it.
APNs
Apple's push service. The server sends to iOS apps through it directly; Firebase is not involved.
push token
The address of one app install.
register() returns it with the provider that delivers to it.provider
apns on iOS, fcm on Android and the web. The server picks the sender by it.deviceId
A random id the app keeps in its own storage, so a rotated token replaces the old one.
VAPID key
The web push key pair. Its public half goes in the client env as
vapidKey.service account
The Firebase Admin credential the server sends to FCM with. It never reaches the client.
APNs auth key
The
.p8 key the server signs its APNs requests with. One key serves both APNs environments.aps-environment
The iOS entitlement that says whether the app's tokens belong to APNs development or production.
What you prepare
Item
Web
Android
iOS
In the consoles
Firebase app
✓
✓
One Firebase project, with the web app and the Android app registered in it.
VAPID key
✓
A Web Push certificate key pair, generated in Firebase's Cloud Messaging settings.
Push capability
✓
Push Notifications turned on for the App ID in Apple Developer, so its profiles carry the entitlement.
APNs auth key (.p8)
✓
Created under Keys in Apple Developer, with its Key ID and your Team ID. It goes to your server.
In the app folder
env.client.*
✓
The public Firebase web config and
vapidKey, under firebase.google-services.json
✓
The Android Firebase config, named by
native.android.googleServices in akan.config.ts.permissions: ["push"]
✓
✓
Adds the native push plugin; it goes in
native in akan.config.ts.On the server
pushNoti.firebase
✓
✓
In
env.server.*: the service account the server sends to FCM with.pushNoti.apns
✓
In
env.server.*: the APNs key, its Key ID, your Team ID and the app's bundle id.✓NeededNot needed
One Native Plugin
A native app gets push from the runtime's
push plugin, and permissions: ["push"] in native is all that adds it. There is no package to install. The plugin speaks each platform's own service:iOS · APNs
Registers with APNs directly, with no Firebase SDK. A tap, and a message that arrives in front, come through the shell's notification router.
push.register() → { provider: "apns" }Android · FCM
An FCM module pinned with the runtime. The build reads
google-services.json itself, so no Gradle plugin is involved.push.register() → { provider: "fcm" }usePushNotification() hides which is which: it calls the plugin in a native shell and Firebase in a browser, and hands back one PushToken shape either way. Which permission adds which plugin is on Setup.Web Push
Web push needs no native project at all. Register a Firebase web app and copy its public config into the client env.
- Create or open a web app in Firebase Console.
- Copy its public config into
env.client.*, underfirebase. - Generate a Web Push certificate key pair and put its public key in
vapidKey.
The client env file then looks like this:
apps/myapp/env/env.client.local.ts
- Only public values go here.
env.client.*ships to the browser; the server's service account belongs inenv.server.*. - Four fields are required. Without
apiKey,projectId,messagingSenderIdorappId,register()returnsundefinedon the web. - One file per environment.
env.client.tspicksenv.client.<env>.tsbyAKAN_PUBLIC_ENV, so fill in every environment you deploy. - The service worker is generated. With
firebasein the client env,akan syncwritespublic/firebase-messaging-sw.jsfor each environment.
Android Push
Android push is a Firebase Android app whose package name matches
native.appId exactly, plus one config file native.android.googleServices names.- Open Firebase Console and select the project.
- Add an Android app.
- Enter the same package name as
native.appId. - Download
google-services.json. - Place it at
apps/myapp/secrets/google-services.json.
Then name it in
native.android in akan.config.ts:apps/myapp/akan.config.ts
- The build converts the file itself. It picks the client whose package name is the target's
appId(a debug build falls back to it too), and a file without that app fails the build with the names it has. secrets/, notpublic/. Everything inpublic/is served to every visitor.secretskeeps the file out of git and carries it withakan upload-envandakan download-env.permissions: ["push"]adds the push plugin andPOST_NOTIFICATIONSto the app.


google-services.json is not the server credential. It is the Android app's Firebase config, not the Firebase Admin service account JSON. The server credential goes in env.server.*, as the last section shows.Android Notification Details
- Foreground. The framework asks the plugin to show a push that arrives while the app is open (banner, list, sound, badge), so it can be tapped like any other.
- Background. FCM draws the notification itself while the app is not in front. A tap opens the app and routes the push's
url. - Channel, icon and color. Firebase posts into its default channel with the launcher icon, which the status bar draws as a gray square.
native.android.pushnames achannel({ id, name, importance? }), asmallIcon(a white-on-transparent PNG in the app folder) and an accentcolorinstead.
iOS Push
iOS push needs no Firebase at all: the app registers with APNs, and the server sends to APNs itself. What you own is the capability on the App ID and the key the server signs with.
- In Apple Developer, open Identifiers, pick the App ID that matches
native.appId, and turn on Push Notifications. - Under Keys, create a key with Apple Push Notifications service enabled and download its
.p8. Apple lets you download it once; note its Key ID and your Team ID. - Put the three into
pushNoti.apnson the server, as the last section shows. - Add
permissions: ["push"]tonative.
Nothing else goes in the config:
apps/myapp/akan.config.ts
- No
GoogleService-Info.plist, no firebase-ios-sdk. The push plugin addsUIBackgroundModesandaps-environmentto the app itself. - An iOS token is an APNs device token, with
provider: "apns". FCM does not accept it, so the server sends it to APNs itself. xcrun simctl pushneeds no server. It hands a payload to a simulator, which tests the tap and the routing. Puturlat the top level, besideaps, as the server does.


Keep the
.p8 on the server. It signs pushes to every app of your team. It belongs in env.server.*, never in env.client.* or public/.Which APNs Environment You Built
You never write
aps-environment: the push plugin declares development, and a build signed with a provisioning profile takes the profile's value. It decides which APNs environment the device's token belongs to.| Command | aps-environment | Used for |
|---|---|---|
| akan start-ios | development | Simulator and development-signed iPhone runs, through the APNs sandbox. |
| akan build-ios | development | A simulator build. |
| akan release-ios | production | The App Store profile: TestFlight and the App Store. |
| akan release-ios --adHoc | production | An ad hoc profile. |
- The server tries both. With
environmentunset, a send goes to production first and, when APNs answersBadDeviceToken(a development build's token), to the sandbox. Setenvironmentto pin one. - One key serves both. An APNs auth key is not tied to an environment, so a development run and a TestFlight build need nothing different on the server.
- A token no environment knows is dropped. A
410, orBadDeviceTokenfrom the last environment tried, removes the token from its owner.
Client Registration
An app that mounts
libs/shared needs no code of its own. Mount Notification.Zone.Initialize once in a signed-in layout: it registers the device again on every visit and on every token a native shell rotates, and never asks for permission.apps/myapp/page/(user)/_layout.tsx
The permission prompt belongs to a user action, because Chrome ignores a request with no gesture behind it and iOS refuses one.
Notification.Util.PushSetting is that switch. A button of your own calls register() and hands the PushToken to the store:apps/myapp/ui/EnablePush.tsx


registerPushToken comes with libs/shared. Without it, hand the PushToken to an endpoint of your own; its fields map one to one onto the DeviceToken shown next.What usePushNotification() returns
Import it from
@libs/util/webkit. Most screens need only register().MethodDescription
register()
Asks for permission, then returns a
PushToken, or undefined when refused or unsupported.getToken()
Returns the token without asking. Registering shows no prompt, so check
getPermission() first.getPermission()
Reads the current permission state.
requestPermission()
Shows the permission prompt and returns the answer.
isSupported()
Whether push can work here: the native plugin in a shell, the Firebase web config in a browser.
onTokenChange(listener)
A native token rotates on its own; the listener gets each new
PushToken. Returns the unsubscribe.initClickBridge()
Routes the browser's notification clicks. The hook runs it on mount; a native shell needs nothing.
- PushToken holds
token,platform(web|android|ios),provider(apns|fcm) anddeviceId, the installation idgetPushDeviceId()keeps in the app's storage. - Built-in storage. With
libs/shared,st.do.registerPushToken(pushToken)stores it on the signed-in user. The next section shows where. - Click routing. Send a
urland a tap opens it through the CSR router. In a native shell the framework routes it from boot, the tap that launched the app included; in a browser the service worker hands it to the open tab. Only a path inside the app is followed.
Where Tokens Live
libs/shared keeps every device's token on its owner: user.notiInfo.deviceTokens, one DeviceToken per installation. The field is secret, so it never leaves the server.Push token lifecycle
Client: register()
Server: addNotiDeviceTokenOfSelf
user.notiInfo.deviceTokens
Server: push(userIds)
Settings accept it?
sendEach by provider
APNs
FCM
User device
Gone?
Server: drop the token
Client: register()
Server: addNotiDeviceTokenOfSelf
user.notiInfo.deviceTokens
Server: push(userIds)
Settings accept it?
yes
sendEach by provider
APNs
FCM
Gone?
yes
User device
Server: drop the token
deviceToken.constant.ts
The scalar holds what
register() returned, plus when the server stored it:libs/shared/lib/__scalar/deviceToken/deviceToken.constant.ts
- One entry per installation. Registering again with the same
tokenor the samedeviceIdreplaces that entry, so a rotated token does not pile up. updatedAtis the server's. It is written when the token is registered; the value a client sends is not used.- Signing out drops this device.
signoutUsersends the installation'sdeviceId, so a handed-down phone does not get the previous person's notifications. - Older tokens are skipped. A token stored as a plain string before this shape is not read;
Notification.Zone.Initializeregisters the device again on its next visit.
The endpoints
All three are
User-guarded mutations and queries on the user signal, called through the notification store's registerPushToken, unregisterPushToken and loadPushState:EndpointDescription
addNotiDeviceTokenOfSelf(deviceToken)
Stores this device's
DeviceToken on the caller, replacing its earlier entry.subNotiDeviceTokenOfSelf(token)
Removes one token from the caller: the push switch turned off.
hasNotiDeviceTokenOfSelf(token)
Whether this device is registered, which is what the switch shows.
Selfsupplies the owner, so a client cannot register a token under someone else's account.- Kept off MCP. An agent has no device, so the token endpoints are
mcp: false.
Send And Retire Dead Tokens
notificationService.push(userIds, payload) is the one call a domain service makes. It reads each recipient's settings, sends every accepted device through its own provider, and drops the tokens APNs or FCM call gone.Server credentials
apps/myapp/env/env.server.local.ts
firebaseis the service account from Firebase Console, under Project settings, then Service accounts. Copy the five fields above from the downloaded JSON. Android and the web need it.apnsis the.p8key.privateKeyis the file's text (\nescapes are fine),keyIdandteamIdcome from Apple Developer, andbundleIdis the app'snative.appId. iOS needs it.- Neither is
google-services.json. That file is the Android app's config; these sign every send.


A sender without credentials sends nothing and throws nothing. Its tokens are skipped with one
warn line, such as pushNoti.apns is not configured, and counted as failures.Sending from a service
Load, save, then notify, with the push fire-and-forget:
apps/myapp/lib/order/order.service.ts
- The settings gate is one function.
NotificationService.accepts:blockanddisagreestop everything,fewerlets onlyactionRequiredandessentialthrough, a futurepauseUntilstops everything, and a user without tokens is skipped. - Dead tokens go at once. APNs
410orBadDeviceToken, and FCMmessaging/registration-token-not-registered, remove the token from its owner in the same call. - It never throws. A push is best effort:
push()answers what it reached (targetUserIds,tokenNum,successCount,prunedTokens), and a failed send never fails the caller's own work. - A megaphone takes the same gate. An admin notification of
type: "all"goes to every active user, 500 at a time, throughacceptslike any other push.
What push() takes
titlestringrequired
The notification title.
levelcnst.NotiLevelrequired
actionRequired, notice, essential, suggestion or advertise. The settings gate reads it.contentstring
The notification body.
contentKeystring
A dictionary key for the body instead, resolved in the app's default locale.
urlstring
Where a tap lands: a path inside the app.
tagstring
A collapse key: a second push with the same tag replaces the first.
imageUrlstring
An image shown in the notification.
badgenumber
The app icon's badge count.
No topics. A topic cannot hold an APNs token, cannot ask a person's settings and never reports a dead token, so every send goes to stored tokens. Without
libs/shared, call PushNotificationServer.sendEach(targets, message) from @libs/util/srvkit with { token, provider } targets, and stop storing the invalidTokens it returns.