Building Applications Hosted in the MJ Mobile App
How to put an application you have already built on MemberJunction onto a phone, without forking the mobile app and without maintaining a second navigation model.
Audience: any team shipping an application on MJ/MJE that wants a mobile presence.
Prerequisite: your application already exists as an MJ: Applications row with nav items —
i.e. it runs in MJ Explorer.
Worked example in the repo: packages/MobileApp/src/sample-app/FieldNotesResource.tsx.
1. The one idea
Section titled “1. The one idea”MJ Explorer is not a product. It is a host. A stock install has 27 MJ: Applications rows;
each declares nav items that name a DriverClass, resolved at runtime through
MJGlobal.ClassFactory against BaseResourceComponent. Chat, Data Explorer, Actions, AI — all
hosted applications, not hard-coded screens.
The mobile app is the same host with a different deployment: the same metadata rows, the same
registry, resolved against BaseMobileResource instead.
MJ: Applications ──┬──► MJ Explorer ──► ClassFactory ──► BaseResourceComponent (Angular) (one row) │ └──► Mobile app ──► ClassFactory ──► BaseMobileResource (React Native)One metadata row. Two hosts. No duplicated navigation, no second source of truth.
2. Before you write anything — check what you already get
Section titled “2. Before you write anything — check what you already get”There are three tiers of support, and tier 1 costs you nothing.
| Tier | What you do | What users get |
|---|---|---|
| T1 — Generic | Nothing. | Your entities, queries, lists, dashboards and agents are already in metadata, so native record browsing, query running, dashboards and agent chat over your data already work. |
| T2 — Native surface | Register a BaseMobileResource for a nav item | A purpose-built mobile screen for that section. Your desktop driver is untouched. |
| T3 — Mobile-only | Add a nav item whose driver exists only on mobile | Camera capture, offline field entry, push-first approvals — workflows that make no sense on a desktop. |
Install the mobile app against your instance and look before you build. For many applications T1 is most of the value, and the right T2 list is one or two screens, not a port of your whole app.
T1 is the Explorer tab, not your application’s navigation. The two are easy to conflate. T1 is the browsing the app already does over any deployment’s metadata — entities, records, queries, dashboards — reachable from the Explorer tab with no application-specific code at all. What your application’s own nav items open is a separate question, answered by resource type:
Nav item ResourceTypeWhat the app shell does with it CustomResolves DriverClassthroughBaseMobileResource— this is T2/T3, and it is what every application MJ ships uses todayDashboardsRenders the dashboard RecordIDnames, the same renderer the Explorer route usesAnything else Shows an honest “opens on desktop” card Adding another generic type is a registration in
src/host/generic-resources.tsx, keyed by the type name — the shell has one resolution path and does not special-case either kind.
The question to ask is not “which of our screens do we have” but “what does someone do standing up?” Usually: look something up, approve something, ask the agent, capture something.
3. Add a mobile surface (T2)
Section titled “3. Add a mobile surface (T2)”Three steps. Nothing else.
Step 1 — Write a screen
Section titled “Step 1 — Write a screen”An ordinary React Native function component. It receives MobileResourceProps and is otherwise
indistinguishable from any other screen.
import type { MobileResourceProps } from '@/host/BaseMobileResource';
function FieldNotesScreen({ ApplicationName, NavItem }: MobileResourceProps) { // RunView, Metadata, GetEntityObject — the same MJ object model as everywhere else. return /* … */;}MobileResourceProps gives you ApplicationID, ApplicationName and the NavItem that opened
you. Deliberately nothing else — no navigation object, no shell internals — so a screen written for
this host is not quietly coupled to its chrome.
Step 2 — Wrap it in a resource
Section titled “Step 2 — Wrap it in a resource”import { RegisterClass } from '@memberjunction/global';import { BaseMobileResource } from '@/host/BaseMobileResource';
@RegisterClass(BaseMobileResource, 'FieldNotesResource')export class FieldNotesMobileResource extends BaseMobileResource { public get Component() { return FieldNotesScreen; }
/** Optional: overrides the header. Defaults to the nav item's label. */ public override get Title(): string | null { return 'Field Notes'; }
/** Optional: return false to get the shell's "not available" treatment. */ public override get IsAvailable(): boolean { return true; }}The registration key must be the DriverClass your application already declares in its
MJ: Applications nav metadata. That string is the entire contract between the two hosts.
Why a class wrapping a component, rather than the component itself?
ClassFactoryinstantiates whatever it resolves, and React function components must not be called as constructors. The class is a thin, inert holder.
Step 3 — Register it in the build manifest
Section titled “Step 3 — Register it in the build manifest”export function LoadHostedMobileResources(): void { LoadFieldNotesMobileResource();}…and export the guard from your module:
export function LoadFieldNotesMobileResource(): void { /* intentionally empty — see below */}This guard is not ceremony. @RegisterClass runs as a module side effect, and a bundler that
sees no import of your module removes it. Your class then never registers, the nav item silently
falls back to “opens on desktop”, and it works in development while failing in a production build.
Every registering module in MJ carries one of these, for exactly this reason.
4. Why mobile needs a build manifest and the web does not
Section titled “4. Why mobile needs a build manifest and the web does not”This is the one real asymmetry between the two hosts, and it is worth understanding rather than working around.
MJ Explorer can discover application code at runtime: it ships a JavaScript bundle, and a lazily-loaded chunk registers itself when it arrives. A native app cannot — its JavaScript is compiled into the binary at build time, and App Store policy is hostile to shipping executable code out of band.
So “which applications does this build host” is a build-time decision on mobile and a runtime
one on the web. src/host/registry.ts is where that decision lives. A deployment hosting a
different set of applications builds with a different manifest; no application’s code changes to be
included or excluded.
Everything else — the metadata, the nav items, the driver names, the resolution — is identical.
5. What carries over, and what should not
Section titled “5. What carries over, and what should not”Carries over untouched
Section titled “Carries over untouched”Entities and the generated ORM · agents, skills and plan mode · actions · queries and views · permissions and row-level security · artifacts · conversations and realtime sessions · metadata of every kind.
This is the “one TypeScript brain” dividend. You are not porting your data layer; you are writing presentation.
Needs a native surface
Section titled “Needs a native surface”Anything that is a layout rather than data: bespoke dashboards, visual editors, drag-and-drop, multi-pane workspaces. Also anything touching device capability — camera, biometrics, push, voice, location, offline capture.
Should stay on desktop, deliberately
Section titled “Should stay on desktop, deliberately”Admin and configuration · schema tooling · authoring environments · bulk operations · long-form data entry · anything with an irreducible dense grid.
Say so explicitly. A nav item with no registered mobile driver renders “opens on desktop”, which is a better product than a cramped imitation. Leaving a section off mobile is a decision, not a gap.
6. Reuse the runtimes — do not rebuild them
Section titled “6. Reuse the runtimes — do not rebuild them”The most common mistake is re-implementing orchestration that already exists as a shared pure-TypeScript package.
| You need | Use | Not |
|---|---|---|
| Chat with an agent | @memberjunction/conversations-runtime | Your own dispatch, mention parsing, or default-agent logic |
| Voice / realtime | @memberjunction/realtime-runtime | Your own session orchestration |
| Attachments | ConversationUtility for the inline-vs-storage decision | Your own size thresholds |
| Data | Metadata / RunView / RunQuery / GetEntityObject | A bespoke API client |
An application team should be writing presentation. If you find yourself writing agent dispatch or session lifecycle, stop — it exists, and a second copy drifts from the first at the next protocol change.
7. Test it
Section titled “7. Test it”| Layer | How |
|---|---|
| Unit | Vitest, per the repo standard. Mock at the boundary your screen actually calls. |
| Integration | Against live MJAPI, gated on MJ_TEST_JWT. Seed and clean up your own fixtures — a suite that asserts against ambient data only passes on a database someone has already used. |
| E2E | A Maestro flow. Assert that your registered surface mounts inside the shell — that is the only thing which proves ClassFactory resolution works in the real Hermes bundle. |
packages/MobileApp/.maestro/05-app-host.yaml is a working example covering launcher → hosted app
→ registered surface → unregistered fallback.
Two things that will bite you, both found by writing that flow:
- Deep links race the provider boot. A cold launch into a deep link reaches your screen before
the MJ provider has a token, and metadata access throws on an unset provider. Gate on
useMJ().status === 'ready'. accessibilityLabelshadows inner text in the accessibility tree, so Maestro matches the label, not your<Text>. That is correct behaviour — assert on the accessible name, which is also what a screen-reader user hears.
8. Worked example: Sidecar’s LXP
Section titled “8. Worked example: Sidecar’s LXP”learn.sidecar.ai is an AI-native LMS on MJ/MJE. How it lands on mobile:
| LXP surface | Tier | How |
|---|---|---|
| Course / lesson catalog | T1 | Lessons, courses and enrollments are MJ entities — native list and record surfaces render them with zero LXP code. |
| Progress dashboard | T1 | An MJ dashboard; KPI and chart parts render natively. |
| AI tutor chat | T1 → T2 | T1 works immediately via the generic conversation surface. T2 is where LXP’s character lands — warm tutor persona, resume cards, voice states — as a driver over conversations-runtime. Same engine as web, different skin. |
| Tutor voice mode | T2 | realtime-runtime + the RN media host. The co-agent voices LXP’s tutor agent — the target is a runtime parameter, so no LXP-side realtime code at all. |
| Video lesson player | T2 / T3 | Genuinely mobile-native: background audio, lock-screen controls, offline download. |
| Curriculum authoring | desktop | Correctly excluded. |
What LXP writes: a handful of BaseMobileResource drivers and their screens.
What LXP does not write: agent dispatch, default-agent resolution, mention parsing, streaming,
session lifecycle, transcript persistence, tool relay, narration pacing, permissions, entity access.
That ratio — app teams writing presentation, MJ providing orchestration — is the measure of whether this architecture is working.
9. Checklist
Section titled “9. Checklist”- Installed the mobile app against your instance and checked what T1 already gives you
- Picked the one or two workflows that belong on a phone, by what people do standing up
- Screen written as a normal RN component taking
MobileResourceProps -
BaseMobileResourcesubclass registered under the existingDriverClass -
Load*guard exported and called fromsrc/host/registry.ts - Orchestration delegated to the shared runtimes, not re-implemented
- Screens gate on
useMJ().status === 'ready' - Unit + integration + a Maestro flow that mounts your surface
- Sections that belong on desktop deliberately left unregistered
Related
Section titled “Related”plans/mobile-app-react-native/MOBILE-AS-APP-HOST.md— the architecture and its rationalepackages/MobileApp/README.md— the mobile package itselfguides/CONVERSATIONS_UX_STACK_GUIDE.md— the chat runtime you should be reusingguides/REALTIME_CO_AGENTS_GUIDE.md— the realtime stack