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
- App.tsx
- routes.ts
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 }
});
import type { NavaiRoute } from "@navai/voice-mobile";
export const NAVAI_ROUTE_ITEMS: NavaiRoute[] = [
{
name: "inicio",
path: "/",
description: "Pantalla principal del playground mobile",
synonyms: ["home", "principal", "start", "inicio"]
},
{
name: "perfil",
path: "/profile",
description: "Pantalla de perfil",
synonyms: ["profile", "mi perfil", "account"]
},
{
name: "ajustes",
path: "/settings",
description: "Pantalla de configuracion",
synonyms: ["settings", "configuracion", "preferencias", "config"]
},
{
name: "actividad",
path: "/activity",
description: "Pantalla de actividad reciente",
synonyms: ["activity", "historial", "reciente", "activity feed"]
},
{
name: "facturacion",
path: "/billing",
description: "Pantalla de facturacion y pagos",
synonyms: ["billing", "pagos", "suscripcion", "cobros"]
},
{
name: "ayuda",
path: "/help",
description: "Pantalla de ayuda y soporte",
synonyms: ["help", "soporte", "support", "ayuda"]
}
];
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
- Backend server running (see Express Backend Example)
- Expo CLI installed:
npm install -g expo-cli - 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
- 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." };
}
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”
Runnpm run generate:ai-modules before starting the app.
”Voice not working”
- Check microphone permissions
- Verify
NAVAI_API_URLin.env - Check network connectivity
Next Steps
- Add custom functions for mobile-specific features
- Learn about React Native navigation
- Explore mobile voice agent API