Skip to main content
This example demonstrates a production-ready React Native mobile app with voice-controlled navigation using NAVAI.

Project Structure

playground-mobile/
├── src/
│   ├── ai/
│   │   ├── routes.ts                    # Route definitions
│   │   ├── generated-module-loaders.ts  # Auto-generated
│   │   └── functions-modules/           # Custom functions
│   │       ├── session/
│   │       │   └── logout.fn.ts
│   │       └── support/
│   │           └── open-help.fn.ts
│   ├── voice/
│   │   └── VoiceNavigator.tsx          # Voice UI component
│   └── pages/
│       ├── HomeScreen.tsx
│       ├── ProfileScreen.tsx
│       ├── SettingsScreen.tsx
│       ├── ActivityScreen.tsx
│       ├── BillingScreen.tsx
│       └── HelpScreen.tsx
├── App.tsx                              # Main app component
├── app.json                             # Expo configuration
├── package.json
└── .env                                 # Environment variables

Main Application Component

import Constants from "expo-constants";
import {
  resolveNavaiMobileApplicationRuntimeConfig,
  resolveNavaiMobileEnv,
  resolveNavaiRoute,
  type NavaiRoute,
  type ResolveNavaiMobileApplicationRuntimeConfigResult
} from "@navai/voice-mobile";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Image, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { NAVAI_MOBILE_MODULE_LOADERS } from "./src/ai/generated-module-loaders";
import { NAVAI_ROUTE_ITEMS } from "./src/ai/routes";
import { ActivityScreen } from "./src/pages/ActivityScreen";
import { BillingScreen } from "./src/pages/BillingScreen";
import { HelpScreen } from "./src/pages/HelpScreen";
import { HomeScreen } from "./src/pages/HomeScreen";
import { ProfileScreen } from "./src/pages/ProfileScreen";
import { SettingsScreen } from "./src/pages/SettingsScreen";
import { VoiceNavigator } from "./src/voice/VoiceNavigator";

// Read environment from Expo config and process.env
function readPlaygroundMobileEnv() {
  const extra = (Constants.expoConfig?.extra ?? {}) as Record<string, unknown>;
  return resolveNavaiMobileEnv({
    sources: [extra, process.env as Record<string, unknown>]
  });
}

// Resolve runtime configuration for NAVAI
async function resolvePlaygroundMobileRuntimeConfig(): Promise<ResolveNavaiMobileApplicationRuntimeConfigResult> {
  const env = readPlaygroundMobileEnv();
  const apiBaseUrl = env.NAVAI_API_URL?.trim();

  if (!apiBaseUrl) {
    throw new Error(
      "[navai] NAVAI_API_URL is required in .env. Example: NAVAI_API_URL=http://<YOUR_LAN_IP>:3000"
    );
  }

  return resolveNavaiMobileApplicationRuntimeConfig({
    moduleLoaders: NAVAI_MOBILE_MODULE_LOADERS,
    defaultRoutes: NAVAI_ROUTE_ITEMS,
    env,
    apiBaseUrl,
    emptyModuleLoadersWarning:
      "[navai] No generated module loaders found. Run generate:ai-modules script."
  });
}

// Simple routing: map paths to screen components
function renderScreen(path: string): ReactElement | null {
  switch (path) {
    case "/":
      return <HomeScreen />;
    case "/profile":
      return <ProfileScreen />;
    case "/settings":
      return <SettingsScreen />;
    case "/help":
      return <HelpScreen />;
    case "/activity":
      return <ActivityScreen />;
    case "/billing":
      return <BillingScreen />;
    default:
      return null;
  }
}

export default function App() {
  const [runtime, setRuntime] = useState<ResolveNavaiMobileApplicationRuntimeConfigResult | null>(null);
  const [runtimeLoading, setRuntimeLoading] = useState(true);
  const [runtimeError, setRuntimeError] = useState<string | null>(null);
  const [activePath, setActivePath] = useState("/");

  const routes = useMemo<NavaiRoute[]>(
    () => (runtime?.routes.length ? runtime.routes : NAVAI_ROUTE_ITEMS),
    [runtime]
  );

  // Load runtime configuration on mount
  useEffect(() => {
    let cancelled = false;

    void resolvePlaygroundMobileRuntimeConfig()
      .then((result) => {
        if (cancelled) return;
        setRuntime(result);
        setRuntimeError(null);
      })
      .catch((nextError) => {
        if (cancelled) return;
        setRuntime(null);
        setRuntimeError(nextError instanceof Error ? nextError.message : String(nextError));
      })
      .finally(() => {
        if (!cancelled) setRuntimeLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, []);

  // Validate active path exists in routes
  useEffect(() => {
    if (routes.some((route) => route.path === activePath)) return;
    setActivePath(routes[0]?.path ?? "/");
  }, [routes, activePath]);

  // Navigation handler
  const navigate = useCallback(
    (input: string) => {
      const trimmed = input.trim();
      if (!trimmed) return;

      // Direct path navigation
      if (trimmed.startsWith("/")) {
        setActivePath(trimmed);
        return;
      }

      // Resolve by route name or synonym
      const nextPath =
        routes.find((route) => route.path === trimmed)?.path ??
        resolveNavaiRoute(trimmed, routes) ??
        null;

      if (!nextPath) return;
      setActivePath(nextPath);
    },
    [routes]
  );

  const mainScreen = renderScreen(activePath);
  const activeRoute = routes.find((route) => route.path === activePath);

  return (
    <View style={styles.root}>
      <ScrollView contentContainerStyle={styles.content}>
        <Image
          source={require("./assets/icon_navai.jpg")}
          style={styles.logo}
          accessibilityLabel="NAVAI logo"
        />
        <Text style={styles.eyebrow}>NAVAI MOBILE PLAYGROUND</Text>
        <Text style={styles.title}>Voice-first app navigation</Text>
        <Text style={styles.description}>
          Say: "llevame a perfil", "abre ajustes" or "cierra sesion".
          The agent can navigate routes and execute internal app functions.
        </Text>

        {/* Route navigation chips */}
        <View style={styles.navRow}>
          {routes.map((route) => {
            const active = route.path === activePath;
            return (
              <Pressable
                key={route.path}
                style={[styles.routeChip, active ? styles.routeChipActive : null]}
                onPress={() => setActivePath(route.path)}
              >
                <Text style={active ? styles.routeChipTextActive : styles.routeChipText}>
                  {route.name}
                </Text>
              </Pressable>
            );
          })}
        </View>

        <VoiceNavigator
          activePath={activePath}
          runtime={runtime}
          runtimeLoading={runtimeLoading}
          runtimeError={runtimeError}
          navigate={navigate}
        />

        {mainScreen ? (
          mainScreen
        ) : activeRoute ? (
          <View style={styles.card}>
            <Text style={styles.cardTitle}>{activeRoute.name}</Text>
            <Text style={styles.muted}>Path: {activeRoute.path}</Text>
          </View>
        ) : (
          <View style={styles.card}>
            <Text style={styles.cardTitle}>Route not found</Text>
            <Text style={styles.muted}>Path: {activePath}</Text>
          </View>
        )}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: "#0B1220" },
  content: { padding: 16, gap: 12 },
  logo: {
    width: 64,
    height: 64,
    borderRadius: 16,
    borderWidth: 1,
    borderColor: "rgba(255, 255, 255, 0.16)"
  },
  eyebrow: { color: "#7DD3FC", fontSize: 12, letterSpacing: 1.2 },
  title: { color: "#FFFFFF", fontSize: 36, fontWeight: "700" },
  description: { color: "#CBD5E1", fontSize: 14 },
  navRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
  routeChip: {
    borderWidth: 1,
    borderColor: "#334155",
    backgroundColor: "#0F172A",
    borderRadius: 999,
    paddingHorizontal: 10,
    paddingVertical: 6
  },
  routeChipActive: { borderColor: "#67E8F9", backgroundColor: "#164E63" },
  routeChipText: { color: "#CBD5E1", fontSize: 12 },
  routeChipTextActive: { color: "#ECFEFF", fontSize: 12, fontWeight: "700" },
  card: { padding: 12, backgroundColor: "#111827", borderRadius: 12, gap: 8 },
  cardTitle: { color: "#F8FAFC", fontWeight: "700", fontSize: 16 },
  muted: { color: "#94A3B8", fontSize: 14 }
});

Voice Navigator Component

import {
  useMobileVoiceAgent,
  type ResolveNavaiMobileApplicationRuntimeConfigResult
} from "@navai/voice-mobile";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";

export type VoiceNavigatorProps = {
  activePath: string;
  runtime: ResolveNavaiMobileApplicationRuntimeConfigResult | null;
  runtimeLoading: boolean;
  runtimeError: string | null;
  navigate: (path: string) => void;
};

export function VoiceNavigator({
  activePath,
  runtime,
  runtimeLoading,
  runtimeError,
  navigate
}: VoiceNavigatorProps) {
  const agent = useMobileVoiceAgent({
    runtime,
    runtimeLoading,
    runtimeError,
    navigate
  });

  const canStart = !agent.isConnecting && !agent.isConnected;

  return (
    <View style={styles.card}>
      <Text style={styles.sectionTitle}>Voice Navigator</Text>
      <Text style={styles.status}>Active route: {activePath}</Text>

      {runtimeLoading ? <Text style={styles.muted}>Loading runtime...</Text> : null}
      {runtimeError ? <Text style={styles.error}>{runtimeError}</Text> : null}

      {!agent.isConnected ? (
        <Pressable style={styles.button} onPress={() => void agent.start()} disabled={!canStart}>
          {agent.isConnecting ? (
            <ActivityIndicator color="#111827" />
          ) : (
            <Text style={styles.buttonText}>Start Voice</Text>
          )}
        </Pressable>
      ) : (
        <Pressable style={[styles.button, styles.buttonStop]} onPress={() => void agent.stop()}>
          <Text style={styles.buttonText}>Stop Voice</Text>
        </Pressable>
      )}

      <Text style={styles.status}>Status: {agent.status}</Text>
      {agent.error ? <Text style={styles.error}>{agent.error}</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    borderWidth: 1,
    borderColor: "#1E293B",
    backgroundColor: "#111827",
    borderRadius: 12,
    padding: 12,
    gap: 8
  },
  sectionTitle: { color: "#F8FAFC", fontWeight: "700" },
  status: { color: "#C4B5FD" },
  muted: { color: "#94A3B8" },
  error: { color: "#FCA5A5" },
  button: {
    marginTop: 4,
    borderRadius: 8,
    backgroundColor: "#67E8F9",
    alignItems: "center",
    justifyContent: "center",
    minHeight: 42
  },
  buttonStop: { backgroundColor: "#FCA5A5" },
  buttonText: { color: "#111827", fontWeight: "700" }
});

Package Configuration

{
  "name": "@navai/playground-mobile",
  "version": "0.1.0",
  "private": true,
  "main": "index.js",
  "scripts": {
    "generate:ai-modules": "node ../../node_modules/@navai/voice-mobile/bin/generate-mobile-module-loaders.mjs",
    "predev": "npm run generate:ai-modules",
    "preandroid": "npm run generate:ai-modules",
    "preios": "npm run generate:ai-modules",
    "dev": "expo start",
    "android": "expo run:android",
    "ios": "expo run:ios",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@navai/voice-mobile": "^0.1.0",
    "babel-preset-expo": "~54.0.10",
    "expo": "~54.0.0",
    "expo-asset": "~12.0.12",
    "expo-clipboard": "~8.0.7",
    "expo-constants": "~18.0.13",
    "react": "19.1.0",
    "react-native": "0.81.5",
    "react-native-webrtc": "^124.0.7"
  },
  "devDependencies": {
    "@types/react": "~19.1.10",
    "dotenv": "^16.6.1",
    "typescript": "^5.7.3"
  }
}

Environment Configuration

Create a .env file in the project root:
# IMPORTANT: Use your local network IP, not localhost
# Find your IP: ifconfig (Mac/Linux) or ipconfig (Windows)
NAVAI_API_URL=http://192.168.1.100:3000
Mobile devices cannot access localhost on your computer. You must use your computer’s LAN IP address.

Running the Application

Prerequisites

  1. Backend server running (see Express Backend Example)
  2. Expo CLI installed: npm install -g expo-cli
  3. Expo Go app on your mobile device

Development

# Install dependencies
npm install

# Generate module loaders
npm run generate:ai-modules

# Start Expo development server
npm run dev
Scan the QR code with:
  • iOS: Camera app
  • Android: Expo Go app

Native Builds

# Build for Android
npm run android

# Build for iOS (Mac only)
npm run ios

Custom Functions

Custom functions work the same as in React web apps:
// src/ai/functions-modules/session/logout.fn.ts
export function logout_user(context: { navigate: (path: string) => void }) {
  // Platform-specific logout logic here
  context.navigate("/");
  return { ok: true, message: "Session closed." };
}
Run npm run generate:ai-modules after adding new functions.

Key Differences from Web

1. Runtime Configuration

Mobile apps require runtime config resolution:
const runtime = await resolveNavaiMobileApplicationRuntimeConfig({
  moduleLoaders: NAVAI_MOBILE_MODULE_LOADERS,
  defaultRoutes: NAVAI_ROUTE_ITEMS,
  env,
  apiBaseUrl
});

2. Manual Routing

Unlike React Router, you handle routing manually:
function renderScreen(path: string) {
  switch (path) {
    case "/":
      return <HomeScreen />;
    // ...
  }
}

3. Environment Variables

Access via Expo Constants:
const extra = Constants.expoConfig?.extra ?? {};
const env = resolveNavaiMobileEnv({
  sources: [extra, process.env]
});

4. Network Configuration

Must use LAN IP instead of localhost for API URL.

Troubleshooting

”Cannot connect to backend”

  • Verify backend is running: curl http://<YOUR_IP>:3000/health
  • Check firewall settings
  • Ensure mobile device is on same network

”Module loaders not found”

Run npm run generate:ai-modules before starting the app.

”Voice not working”

  • Check microphone permissions
  • Verify NAVAI_API_URL in .env
  • Check network connectivity

Next Steps

Build docs developers (and LLMs) love