The Expo app
The native iOS/Android app in apps/mobile — its Expo stack, how to run it, and how it shares logic with the web app.
The native app lives in apps/mobile. It is a real iOS/Android client built with Expo and expo-router that talks to the same Valcraven backend as apps/web — the same auth, the same API routes, the same realtime Worker. It does not re-implement your API surface: every request goes through the typed client in @valcraven/core, so schemas and endpoints stay in one place.
The mobile app is opt-in.
/valcraven-createscaffolds a web-only project by default; the Expo workspace is added when you want a phone client. If your project predates the monorepo layout,/valcraven-mobilizeperforms the structural migration that makes mobile adoption possible.
The stack
The pinned versions live in apps/mobile/package.json. The core pieces:
| Concern | Package | Notes |
|---|---|---|
| Framework | expo ^54.0.0 | Pinned to SDK 54 for Expo Go compatibility — do not bump to a newer SDK casually (see gotchas). |
| Native runtime | react-native 0.81.5 | React Native. |
| UI runtime | react / react-dom 19.1.0 | Pinned to 19.1.0 via the repo-root overrides (npm only honors overrides at the workspace root). |
| Routing | expo-router ~6.0.24 | File-based routing; screens live in src/app/. |
| Auth | @better-auth/expo 1.6.13 + better-auth 1.6.13 | The only mobile-specific auth layer. |
| Secure session storage | expo-secure-store | Encrypted device storage for the session cookie. |
| Push notifications | expo-notifications + expo-device | Native push (dev builds only — see gotchas). |
| Shared logic | @valcraven/core ("*") | Local workspace package — schemas, types, and the typed API client. |
The app also pulls in expo-image-picker (avatar upload), expo-web-browser (OAuth + Stripe portal), expo-file-system + expo-sharing (data export), react-native-qrcode-svg (2FA enrollment), and expo-network (offline detection).
Where the code lives
apps/mobile/
├── app.json # Expo config (name, slug, scheme "valcraven", plugins)
├── package.json # pinned deps + scripts
├── .env.example # EXPO_PUBLIC_API_URL
└── src/
├── app/ # expo-router file-based routes
│ ├── (app)/ # protected screens: index, chat, notifications, settings
│ ├── (auth)/ # sign-in, sign-up, forgot-password, two-factor
│ ├── privacy-policy.tsx, terms.tsx
│ └── _layout.tsx # root layout (error boundary, theme, offline notice)
├── components/ # app-header, screen, onboarding, theme-toggle, etc.
└── lib/ # auth-client, api, theme, colors, push-notifications, realtimeRoute groups map to auth state: (auth) screens are for signed-out users, (app) screens require a session. The valcraven:// URL scheme (declared in app.json) makes every route deep-linkable — for example valcraven://chat opens the AI chat screen.
Prerequisites
- Node 20 or 22. Install from the repo root with
npm install— the workspace hoists everything. - To run on a physical device: the Expo Go app from the iOS App Store or Google Play. No Xcode or Android Studio needed.
- To run in the iOS Simulator: the full Xcode app (Command Line Tools alone are not enough).
Configure the API URL
The one environment variable the app needs is EXPO_PUBLIC_API_URL — the base URL of the Valcraven backend to authenticate against. Copy apps/mobile/.env.example to .env and set it for your target:
| Target | Value |
|---|---|
| iOS Simulator on this Mac | http://localhost:3013 (the default) |
| Physical phone via Expo Go | Your Tailscale dev HTTPS URL, e.g. https://<host>.<tailnet>.ts.net |
| Production | Your deployed Workers URL |
A physical phone cannot reach localhost on your Mac, and iOS requires HTTPS — which is exactly what the Tailscale dev sidecar provides. See Deployment and the project's remote-dev setup for how that URL is served.
Run it
Make sure the backend is running first (npm run dev from the repo root, or point at a deployed URL), then from apps/mobile:
npx expo start
# press 'i' for the iOS Simulator (needs Xcode), or
# scan the QR code with Expo Go on your phoneSign in with an existing Valcraven account. An unauthenticated launch lands on the sign-in screen; once signed in you land on the home dashboard.
How it shares logic with the web app
The mobile app's guiding principle is that auth is the only mobile-specific layer — everything else comes from @valcraven/core.
The typed API client. apps/mobile/src/lib/api.ts calls the exact same createApiClient the web app uses:
import { createApiClient } from "@valcraven/core";
import { authClient, API_URL } from "./auth-client";
export const api = createApiClient({
baseUrl: API_URL,
fetch: (url, init) => fetch(url, init),
headers: () => {
const cookie = authClient.getCookie();
return cookie ? { Cookie: cookie } : {};
},
});The host injects fetch, the base URL, and the session cookie; the request/response shapes and validation come from @valcraven/core. There is zero duplicated request logic between platforms.
Auth. apps/mobile/src/lib/auth-client.ts builds a Better Auth client with the expoClient plugin, which persists the session cookie in expo-secure-store and replays it on every request. The twoFactorClient plugin (the same one the web client uses) drives TOTP verification. The only server-side change the mobile app required is in apps/web/lib/auth.ts: the expo() Better Auth plugin and the valcraven:// trusted origin.
Chat. The chat screen connects over a WebSocket to the user's AI agent on the realtime Worker (apps/realtime) — the single chat transport shared with the web app. Because React Native's WebSocket constructor accepts a headers option, the app injects the Better Auth session cookie directly on the WS upgrade (browsers can't, which is why the web client differs). The wire format lives in apps/mobile/src/lib/agent-socket-protocol.ts; the socket lifecycle in src/lib/use-agent-socket.ts. See AI chat and Architecture → Realtime Worker.
Verifying changes (no device needed)
apps/mobile has no unit-test harness for screen behavior. The gate is a typecheck plus a Metro bundle:
npm run typecheck # tsc --noEmit, resolves @valcraven/core
npx expo export --platform ios # runs Metro; proves the app assemblesThe Mobile (Expo bundle) CI job runs the same two checks, catching a broken import or a Metro resolution regression without a simulator. It runs on demand only (workflow_dispatch, to control Actions cost) — trigger it with gh workflow run mobile.yml. Device-only behavior (keyboard, navigation, push, real auth flows) is verified with a manual QA pass on a physical device or an emulator.
Gotchas
- Expo SDK is pinned to 54. Expo Go on the store tracks the current SDK; a mismatch breaks on-device runs. Don't bump the SDK without a deliberate upgrade.
- React is pinned to
19.1.0at the repo root. npm only honorsoverridesdeclared at the workspace root, so React is unified there — never pin it per-app. - Push notifications only work in dev builds, not Expo Go. The code no-ops gracefully in Expo Go.
npm run typecheckis the gate, not lint.expo lintis not configured; running it bootstrapseslint-config-expoon first invocation, which you don't want sneaking into an unrelated change.- Keyboard avoidance. Under Expo SDK 54's Android edge-to-edge, wrap screens in a
KeyboardAvoidingViewwithbehavior="padding"(not the oldPlatform.OS === "ios" ? "padding" : undefinedidiom), or bottom-anchored inputs get covered by the keyboard.