Files
hapi/docs/api/native-companion-contract.md
T

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 from didRegisterForRemoteNotificationsWithDeviceToken.
  • pushKey: required for ios - 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 same DELETE /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.