Skip to content

Architecture

Mobile client

The client is a React Native app with a Kotlin native layer. The split is not cosmetic. It follows one rule, and that rule decides whether the product works at all.

The command pipeline never crosses the JavaScript bridge.

Wake word, capture, voice activity detection, upload, plan execution and speech output are all Kotlin. React Native owns screens, navigation, settings and account state.

Why this is not a preference. An AccessibilityService is a framework component declared in the manifest and started by the operating system. It runs when the app’s UI does not exist and when the JavaScript runtime has been torn down.

A pipeline that depended on JavaScript would stop the moment the user left the app — which is exactly when they need it.

flowchart TD
    accTitle: Android process layout
    accDescr: Inside one Android process, the React Native layer holds screens and app state. The Kotlin layer holds the accessibility service executor, the voice pipeline, the API client and DataStore. The UI and pipeline talk over a TurboModule. The pipeline sends events to the UI and uses the API client, the executor and DataStore. The Android OS starts and may restart the accessibility service.
    subgraph proc[Android process]
        subgraph js[React Native · Hermes]
            ui[Screens: onboarding,<br/>settings, consent, history]
            store[App state]
        end
        subgraph kt[Kotlin]
            svc[AccessibilityService<br/>executor]:::core
            pipe[Voice pipeline<br/>wake word, VAD, capture]:::core
            net[API client]
            ds[(DataStore<br/>service state)]
        end
        ui <-->|TurboModule| pipe
        pipe -->|events| ui
        pipe --> net
        pipe --> svc
        pipe --> ds
        ui --> ds
    end
    os([Android OS]):::person -->|starts, may restart| svc

The operating system owns the executor’s lifecycle, not the app. So the Kotlin layer keeps its own state of record in DataStore rather than in the JavaScript store. The UI reads that state when it comes back.

Concern Layer Reason
Onboarding, language choice, consent screens React Native Iteration speed on the screens that change most
Settings, history, account React Native Ordinary product UI, no latency budget
Wake-word detection Kotlin Always on, runs without a UI
Audio capture and VAD Kotlin AudioRecord, real time, cannot tolerate bridge latency
Command upload and retry Kotlin Must survive the UI being destroyed
Accessibility service and gesture dispatch Kotlin Framework component; no JavaScript equivalent exists
View-tree reduction Kotlin Runs per command, on the hot path
Text to speech Kotlin Must speak when no UI is present
Navigation and app state React Native Standard

Three channels, each one-directional in intent, defined in one TurboModule specification.

Channel Direction Carries Frequency
Commands JS → Kotlin Start listening, stop, change language, revoke consent User actions only
Events Kotlin → JS Pipeline state, last command outcome, microphone status While the UI is foregrounded
Queries JS → Kotlin Current service state, permission status On screen mount

Four rules keep the bridge from becoming a liability:

  • Outcomes, never content. No audio, no transcript and no action plan ever crosses the bridge.
  • Drop, don’t queue. Events are dropped when no UI is listening. A backlog of stale events on resume is worse than none.
  • Nothing blocks. Every bridge call is fire-and-forget or has a timeout. A hung JavaScript context must never block the pipeline.
  • The spec is the contract. The TurboModule specification is code-generated for both sides.

The bridge is also a security boundary — JavaScript can ask the pipeline to listen, never ask the executor to act. See Security.

Cost Size Mitigation
APK growth Roughly 8–15 MB Hermes and ABI splits
Cold start JavaScript bundle parse on first launch Wake word starts from Kotlin before the bundle loads
A second accessibility surface React Native views need their own TalkBack checks Screen-by-screen TalkBack audit gates release
Two languages in one repository Onboarding cost for contributors One well-drawn boundary, documented here

The cost lands on screens the user visits rarely. The pipeline they use thirty times a day is untouched by it.

apps/mobile/
├── src/ React Native
│ ├── features/
│ │ ├── onboarding/ language, permissions, consent
│ │ ├── settings/ preferences, data export, revocation
│ │ ├── history/ past commands, outcomes only
│ │ └── account/ subscription, sign-in
│ ├── native/ TurboModule specs + typed wrappers
│ ├── navigation/
│ └── design/ tokens, one modal system, one error system
└── android/src/main/kotlin/
├── pipeline/ wake word, VAD, capture, upload, retry
├── executor/ AccessibilityService, gestures, allowlist
├── accessibility/ view-tree reduction, node matching
├── speech/ TTS, announcement queue
├── network/ generated API client, tracing
└── bridge/ TurboModule implementation

bridge/ is the only Kotlin package React Native may reach. It is also the only Kotlin package that knows React Native exists.