0018. Native apps are API clients
Status: accepted, 2026-10-21
Context
The owner decided on native phone apps with full parity across every service: iOS in SwiftUI and Android in
Kotlin with Jetpack Compose and Material You. ADR 0003 allows one stack only (Go on servers, React and
shadcn in panels, one CLI), and ADR 0006 knows panels and the CLI as clients of id, not phones. Nineteen
services change several times a day, so two more hand-written copies of every screen would drift and never
be finished. The iOS app runs on a free Apple account: no APNs, no TestFlight, no Associated Domains, a
profile that expires after seven days. Android has no Play Store here: the APK comes from the instance.
Decision
- Two client repositories,
ios (Swift 6, SwiftUI, iOS 26 and later) and android (Kotlin, Jetpack
Compose, Material 3, Android 12 and later). This amends 0003 for
clients only: they are the one exception to the one-stack rule; servers stay Go, panels stay React and
shadcn.
- API clients only. Every call the apps make is a public API operation that the CLI or MCP could make
too. No server logic in the apps, no app-only endpoints, no backend-for-frontend.
- No third client language and no shared client code. No Kotlin Multiplatform, React Native or
Flutter. What both apps must agree on is shared as fixtures in kit (screen descriptions, value formats,
links, push payloads) that both test suites load, not as code.
- Three tiers. The daily flows are built by hand (Today, inbox, apps with deploys, logs and a terminal,
status and incidents, errors, the AI chat, approvals); everything else is drawn from descriptions the
services serve (0019); a short, explicit list hands off to the
panel in the system's in-app browser. Full parity means every operation a person can call is reachable
in both apps, and each service's tests check it.
- Sign-in extends 0006. The apps are public, first-party OIDC
clients (
xvii-ios, xvii-android): authorization code with PKCE in the system browser
(ASWebAuthenticationSession, Custom Tabs), so passkeys work without Associated Domains. Refresh tokens
are bound to a non-exportable key on the phone (DPoP at id's token endpoint), and id keeps a registry of
devices the person can see and sign out. Access tokens stay the platform's short bearer JWTs, so
services change nothing.
- Push. Android gets FCM, configured at runtime from the instance (no Firebase files in the APK), or
UnifiedPush, or polling; payloads are encrypted to the device as Web Push is. iOS on a free account has
no remote push: real-time alerts go through Telegram and the web app, the app catches up in the
background and shows local notifications. APNs is a build switch for a paid account.
- Distribution. Android: an APK in the instance's release store, signed by the project, updating itself.
iOS: built and installed from a Mac with the person's own Apple ID (
xcrun devicectl); no App Store or
TestFlight.
Consequences
- Two more toolchains to keep current with yearly OS releases; work on the apps needs Xcode or the Android
SDK, and iOS builds only on a Mac (there is no Mac runner).
- id gains native clients, DPoP proofs and devices; notify gains device channels and an FCM sender; home's
release store gains client releases (
kind: client) with channels.
- A Firebase project (Google) becomes an optional dependency of an instance that wants instant Android
push; nothing depends on xvii.network, and an instance without it still works.
- On iOS without a paid account: no instant push, a profile renewed by reinstalling (automated from the
Mac), and a bundle id per Apple ID for anyone else building the app.
- Design:
superpowers/specs/2026-10-21-native-apps-design.md in this repository.