13 KiB
Native companion API contract (phone + Wear + iOS)
Audience: Implementers of native companion apps (Android phone + Wear OS via FCM, iOS via APNs) that pair with a hapi hub.
Auth: Exchange the pairing code / CLI access token with POST /api/auth:
{ "accessToken": "<code>" }. Use the returned JWT as Authorization: Bearer <token>
for device registration and session actions. POST /api/bind is only for Telegram Mini App
binding (requires Telegram initData).
Scope
A companion implementing this contract is a native client to the same hub the PWA talks to, surfacing notifications and reply / approve actions on a phone or wearable. The hub may run on the operator's development machine or a separate host; agents execute on their CLI/Runner machines.
Native device registration
Register
POST /api/devices/register
{
"token": "<fcm-registration-token>",
"platform": "phone",
"deviceId": "<stable-install-id>",
"pushKey": "<base64 of 32 device-generated random bytes>"
}
platform: "phone" | "wear" | "ios".
pushKey: required for iOS and for Android relay delivery. New Android phone
clients always provide it. Phone registrations without it remain valid for
direct FCM; supplied phone keys must decode to exactly 32 bytes. Wear ignores
this field. The hub canonicalizes and stores phone/iOS keys in the existing
registry; no schema or database version change is required.
deviceId: any string of 1-128 characters chosen by the client (does not have to be a UUID). Must be stable across re-registrations of the same install.
Response: { "ok": true }
Upsert on (namespace, deviceId, platform) - same device re-registering replaces its push token.
Unregister
DELETE /api/devices/register
{
"token": "<fcm-registration-token>"
}
Outbound push (hub → device)
The hub dispatches native notifications independently of PWA visibility. Android uses direct FCM or an encrypted relay depending on hub configuration; iOS uses APNs or the encrypted relay. A successful send to at least one native device sets a per-dispatch gate that suppresses Web Push for that event and namespace. Missing registrations, missing relay encryption keys, and failed sends do not suppress the fallback. Provider acceptance is not a handset receipt.
Data payload (all platforms)
| Key | Example | Purpose |
|---|---|---|
type |
ready |
ready, permission-request, task-notification |
sessionId |
uuid | Target session |
sessionName |
string | Display name (agent - project) |
url |
/sessions/{id} |
Deep link path |
requestId |
uuid | Permission only - approve/deny |
title |
string | Notification title |
body |
string | Notification body |
severity |
info |
info (ready), warning (permission), success / error (task) |
contractVersion |
1 |
Present on every message; see Versioning |
notifySummary |
JSON string | Only on ready: parsed AGENT_NOTIFY_SUMMARY line from agent text, when present |
Direct FCM is data-only. Android relay messages contain the encrypted wrapper below; after decryption the same data contract drives rendering and actions.
Client actions (native - not hub)
| User action | Hub API |
|---|---|
| Send text | POST /api/sessions/:id/messages { "text": "...", "localId": "..." } |
| Allow | POST /api/sessions/:id/permissions/:requestId/approve |
| Deny | POST /api/sessions/:id/permissions/:requestId/deny |
localId is optional in the send-message body - an opaque client-generated id for reconciling the locally shown message with the server-echoed one.
sentFrom extension (optional future): android-phone, android-wear.
Android relay
The official Android app includes the maintainer's Firebase client
configuration. A current hub without private Firebase credentials sends to
https://push.hapi.run; users only pair and allow notifications. Google Play
services and FCM network access are still required. Runtime Firebase project
provisioning and Wear relay delivery are outside this version.
The app persists one deviceId and random pushKey in a dedicated
Keystore-encrypted preference file, excluded from backup and device transfer.
Registration happens only after persistence succeeds. The first upgrade from
the old DataStore ID registers a new identity; token deduplication removes
the old row. Token rotation preserves the key and ID. Registration fans out
to all paired hubs on start, pairing, token rotation and worker retries.
The hub uses the same encrypted envelope as iOS.
POST {relayUrl}/v1/push carries:
{"platform":"android","token":"<opaque FCM token>","envelope":"<base64>","priority":10}
FCM sends only string-valued data: {"hapi_v":"1","hapi_e":"<envelope>"},
with Android priority HIGH (NORMAL for relay priority 5) and the
configured restricted_package_name. No notification or collapse_key
is sent. The existing FCM default TTL is retained. Android decrypts locally,
then passes the v1 fields to the existing renderer, action workers and
session-open/suppress-when-open logic. A present encrypted marker with an
unknown version, missing key, malformed JSON or failed authentication is
dropped, never interpreted as plaintext. Unwrapped direct-FCM data remains
supported.
The encrypted envelope is capped at 3200 Base64 bytes. If needed, the hub
removes notifySummary, then falls back to a minimal HAPI / New activity
notification retaining type, session ID, request ID and contract version.
It never truncates ciphertext or action IDs. If even the minimum exceeds the
cap, the send fails and the registration is retained.
Relay 410 unregistered prunes the token. 413, 429, 501, auth/network
errors and Firebase project mismatch retain it. The relay does not enqueue
or retry sends. The relay and Google see tokens and delivery metadata but
cannot read notification content; the private direct-FCM path retains its
existing plaintext payload.
iOS (APNs)
iOS is a first-class native companion with the same notification contract as Android, delivered over APNs instead of FCM. Like Android relay delivery, the payload is end-to-end encrypted: neither Apple nor the optional hapi push relay can read notification content (PUSH SPEC v1).
Registration
POST /api/devices/register
{
"token": "<hex-apns-device-token>",
"platform": "ios",
"deviceId": "<stable-install-id>",
"pushKey": "<base64 of 32 device-generated random bytes>"
}
token: the hex-encoded APNs device token fromdidRegisterForRemoteNotificationsWithDeviceToken.pushKey: required forios- 32 random bytes generated on-device (e.g.SecRandomCopyBytes), base64-encoded. This is the per-device E2E encryption key; the hub validates it decodes to exactly 32 bytes and rejects the registration otherwise. Keep it in the Keychain (shared with the Notification Service Extension via an app group). Rotate it by re-registering.- Upsert on
(namespace, deviceId, platform), same as Android. Unregister is the sameDELETE /api/devices/register{ "token": ... }.
Encrypted envelope
The plaintext is the exact data payload JSON
(type, sessionId, sessionName?, url?, title, body, severity?,
contractVersion, requestId?, notifySummary?), serialized as
canonical JSON - recursively sorted object keys, no whitespace, absent
optional fields omitted.
Encryption: AES-256-GCM with the device's pushKey:
envelope = base64( nonce(12 random bytes) || ciphertext || tag(16 bytes) )
AAD = ASCII "hapi-push-v1"
Golden test vector (key 0x00..0x1f, nonce 0x00..0x0b):
shared/fixtures/push/envelope-v1.json -
the iOS implementation must reproduce it byte-for-byte.
APNs request (what the device receives)
{
"aps": {
"mutable-content": 1,
"alert": { "title": "HAPI", "body": "New activity" },
"sound": "default"
},
"hapi": { "v": 1, "e": "<envelope>" }
}
The generic "HAPI / New activity" alert is the no-decrypt fallback. The
app's Notification Service Extension (invoked via mutable-content: 1)
decrypts hapi.e with the Keychain pushKey and replaces title/body with
the real content; hapi.v is the envelope version (currently 1).
Delivery headers: apns-push-type: alert, apns-priority: 10,
apns-expiration: 0, apns-collapse-id: "<type>-<sessionId>" (truncated to
64 bytes) - so newer notifications for the same session/type replace older
ones.
Transports: self-host (direct APNs) vs official relay
The hub picks one of two transports via HAPI_IOS_PUSH (or the
iosPushMode field in ~/.hapi/settings.json — every variable below has a
settings.json equivalent, see Environment):
| Mode | Who talks to Apple | Requirements |
|---|---|---|
relay (default) |
The hapi push relay (https://push.hapi.run), which holds the APNs credentials for the official app |
none |
apns |
The hub itself, over HTTP/2 with an ES256 provider JWT | Apple developer account: .p8 auth key, key id, team id, bundle id |
off |
nobody | - |
# default: official relay (zero setup)
HAPI_IOS_PUSH=relay
HAPI_PUSH_RELAY_URL=https://push.hapi.run # override for a self-hosted relay
# self-host: direct APNs, no relay involved
HAPI_IOS_PUSH=apns
APNS_KEY_P8_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
APNS_KEY_ID=XXXXXXXXXX
APNS_TEAM_ID=YYYYYYYYYY
APNS_BUNDLE_ID=your.ios.bundle.id
APNS_ENV=production # or sandbox (Xcode/dev builds)
Relay protocol (for self-hosted relays): POST {relayUrl}/v1/push with
{"platform":"ios","token":"<hex>","envelope":"<base64>","collapseId":"...","priority":10};
responses 200 {ok:true}, 410 {ok:false,"code":"unregistered"} (hub prunes
the device row), 413 / 429 treated as transient.
Dead-token handling mirrors FCM: APNs 410 Unregistered or
400 BadDeviceToken (and relay 410) unregister the device row; transient
errors (auth, throttle, 5xx, network) never do.
Privacy
The notification plaintext exists only on the hub and on the paired device.
Apple's push infrastructure and the relay (official or self-hosted) see
ciphertext plus routing metadata only (APNs token, collapse id, timing,
size). The pushKey never leaves the device except to the operator's own
hub over the authenticated registration call. Operators who prefer zero
third-party involvement beyond Apple run HAPI_IOS_PUSH=apns.
Like the FCM channel, iOS push fires unconditionally for registered devices and suppresses the Web Push fallback for the namespace when a send succeeds (one OS notification, not two).
Environment (hub operator)
HAPI_ANDROID_PUSH |
Behavior |
|---|---|
auto (default) |
Configured FCM_SERVICE_ACCOUNT_PATH: direct FCM; otherwise official relay |
relay |
Force the relay, ignoring private Firebase credentials |
fcm |
Require private Firebase credentials, direct phone/Wear delivery |
off |
Disable Android/Wear push |
# Default: no Firebase configuration needed for official Android apps.
HAPI_ANDROID_PUSH=auto
HAPI_PUSH_RELAY_URL=https://push.hapi.run
# Private-project builds must match the service account's Firebase project.
HAPI_ANDROID_PUSH=fcm
FCM_SERVICE_ACCOUNT_PATH=/path/to/service-account.json
The Firebase project ID comes from the service-account JSON. Missing/broken
explicit credentials or an unknown Android mode disable the channel with a
diagnostic; they never silently switch private tokens to the official
project. One hub selects one Android transport/project; mixing builds from
different Firebase projects is unsupported. iOS transport selection
(HAPI_IOS_PUSH, APNS_*) is independent. The push relay does not depend on
whether the hub's network tunnel (--relay) is enabled.
Push configuration follows the hub-wide rule (env > settings.json >
default; an env value is persisted into ~/.hapi/settings.json on first
sight, so the variable only has to be passed once). settings.json keys:
androidPushMode, fcmServiceAccountPath, iosPushMode, iosPushRelayUrl, apnsKeyP8Path,
apnsKeyId, apnsTeamId, apnsBundleId, apnsEnv. The historical
iosPushRelayUrl setting supplies the shared URL for both platforms. Path
values may use ~.
The native push channel is opt-in: operators who don't run a companion app see no behavior change. When at least one native send succeeds for a notification, Web Push suppresses that notification for the namespace to avoid double-notifying (one in the native app, one from the PWA service worker). PWA-only operators are unaffected.
Versioning
Contract version 1. Breaking changes require data.contractVersion in FCM payload and doc update.