Valcraven Docs
Mobile

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-create scaffolds 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-mobilize performs the structural migration that makes mobile adoption possible.

The stack

The pinned versions live in apps/mobile/package.json. The core pieces:

ConcernPackageNotes
Frameworkexpo ^54.0.0Pinned to SDK 54 for Expo Go compatibility — do not bump to a newer SDK casually (see gotchas).
Native runtimereact-native 0.81.5React Native.
UI runtimereact / react-dom 19.1.0Pinned to 19.1.0 via the repo-root overrides (npm only honors overrides at the workspace root).
Routingexpo-router ~6.0.24File-based routing; screens live in src/app/.
Auth@better-auth/expo 1.6.13 + better-auth 1.6.13The only mobile-specific auth layer.
Secure session storageexpo-secure-storeEncrypted device storage for the session cookie.
Push notificationsexpo-notifications + expo-deviceNative 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, realtime

Route 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:

TargetValue
iOS Simulator on this Machttp://localhost:3013 (the default)
Physical phone via Expo GoYour Tailscale dev HTTPS URL, e.g. https://<host>.<tailnet>.ts.net
ProductionYour 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 phone

Sign 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 assembles

The 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.0 at the repo root. npm only honors overrides declared 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 typecheck is the gate, not lint. expo lint is not configured; running it bootstraps eslint-config-expo on 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 KeyboardAvoidingView with behavior="padding" (not the old Platform.OS === "ios" ? "padding" : undefined idiom), or bottom-anchored inputs get covered by the keyboard.

On this page