Production AI Companion Apps: Streaming, Voice & Monetization
Welcome to the advanced tier. We're building a production-ready AI companion that users will pay for. This means streaming responses, voice input/output, analytics, offline support, and proper monetization infrastructure.
Architecture for Scale
A production companion app has:
- Backend Service — Secure API key handling, request validation, rate limiting
- Streaming Engine — Real-time text display as the AI responds
- Voice Layer — Speech-to-text and text-to-speech
- Analytics — User behavior, retention, subscription metrics
- Monetization — RevenueCat for subscriptions
- Offline Mode — SQLite-based offline responses
- Security — Encrypted storage, privacy compliance
Backend with Cloudflare Workers
Never expose API keys in production. Use a serverless backend:
// wrangler.toml
name = "companion-api"
type = "javascript"
account_id = "your-account"
workers_dev = true
routes = [
{ pattern = "api.rorklab.com/*", zone_id = "your-zone-id" }
]
[env.production]
vars = { ENVIRONMENT = "production" }
[[env.production.kv_namespaces]]
binding = "RATE_LIMIT"
id = "your-kv-id"// src/index.ts (Cloudflare Worker)
export interface Env {
CLAUDE_API_KEY: string;
RATE_LIMIT: KVNamespace;
}
export default {
async fetch(
request: Request,
env: Env,
context: ExecutionContext
): Promise<Response> {
if (request.method === 'POST' && request.url.includes('/api/chat')) {
return handleChatRequest(request, env);
}
return new Response('Not found', { status: 404 });
},
};
async function handleChatRequest(request: Request, env: Env) {
const { messages, conversationId } = await request.json();
const userIP = request.headers.get('CF-Connecting-IP');
// Rate limiting
const rateLimitKey = `ratelimit:${userIP}`;
const count = await env.RATE_LIMIT.get(rateLimitKey);
const currentCount = count ? parseInt(count) + 1 : 1;
if (currentCount > 100) {
return new Response('Rate limit exceeded', { status: 429 });
}
await env.RATE_LIMIT.put(rateLimitKey, String(currentCount), {
expirationTtl: 3600,
});
// Stream response from Claude
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.CLAUDE_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1500,
stream: true,
messages: messages,
}),
});
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}Streaming Responses in React Native
Display AI responses in real-time:
// services/streamingApi.ts
export async function streamChatResponse(
messages: Array<{ role: string; content: string }>,
onChunk: (chunk: string) => void,
onComplete: () => void
): Promise<void> {
const response = await fetch(
'https://api.rorklab.com/api/chat',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
}
);
if (!response.ok) {
throw new Error('Stream failed');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No reader');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.startsWith('data: ')) {
try {
const json = JSON.parse(line.slice(6));
if (
json.type === 'content_block_delta' &&
json.delta.type === 'text_delta'
) {
onChunk(json.delta.text);
}
} catch (e) {
// Parse error, skip
}
}
}
buffer = lines[lines.length - 1];
}
onComplete();
}// components/StreamingChat.tsx
import React, { useState } from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { streamChatResponse } from '../services/streamingApi';
interface Props {
messages: Array<{ role: string; content: string }>;
}
export default function StreamingChat({ messages }: Props) {
const [streamingText, setStreamingText] = useState('');
const handleStream = async () => {
setStreamingText('');
try {
await streamChatResponse(
messages,
(chunk) => {
setStreamingText((prev) => prev + chunk);
},
() => {
console.log('Stream complete');
}
);
} catch (error) {
console.error('Stream error:', error);
}
};
return (
<ScrollView style={styles.container}>
{messages.map((msg, idx) => (
<View key={idx} style={styles.messageBubble}>
<Text style={styles.text}>{msg.content}</Text>
</View>
))}
{streamingText && (
<View style={styles.streamingBubble}>
<Text style={styles.text}>{streamingText}</Text>
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 12 },
messageBubble: {
backgroundColor: '#f0f0f0',
padding: 12,
borderRadius: 12,
marginVertical: 8,
},
streamingBubble: {
backgroundColor: '#e3f2fd',
padding: 12,
borderRadius: 12,
marginVertical: 8,
borderLeftWidth: 3,
borderLeftColor: '#2196F3',
},
text: { fontSize: 15, color: '#333' },
});Voice Input & Output
Implement speech-to-text and text-to-speech:
npx expo install expo-speech expo-av expo-permissions// services/voice.ts
import * as Speech from 'expo-speech';
import * as Audio from 'expo-av';
import * as DocumentPicker from 'expo-document-picker';
export async function startListening(
onTranscript: (text: string) => void
): Promise<void> {
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
throw new Error('Audio permission denied');
}
const recording = new Audio.Recording();
await recording.prepareToRecordAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
await recording.startAsync();
setTimeout(async () => {
await recording.stopAndUnloadAsync();
const uri = recording.getURI();
// Send to backend for transcription or use device-side speech recognition
}, 10000);
}
export async function speakText(text: string): Promise<void> {
await Speech.speak(text, {
language: 'en',
rate: 1,
pitch: 1,
});
}
export function stopSpeaking() {
Speech.stop();
}// components/VoiceChat.tsx
import React, { useState } from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { startListening, speakText, stopSpeaking } from '../services/voice';
export default function VoiceChat() {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState('');
const handleMicPress = async () => {
if (isListening) {
setIsListening(false);
return;
}
try {
setIsListening(true);
await startListening((text) => {
setTranscript(text);
});
} catch (error) {
console.error('Listening failed:', error);
setIsListening(false);
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={[styles.micButton, isListening && styles.micActive]}
onPress={handleMicPress}
>
<Text style={styles.micIcon}>
{isListening ? '🎤' : '🎙️'}
</Text>
</TouchableOpacity>
{transcript && <Text style={styles.transcript}>{transcript}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: 'center', paddingVertical: 20 },
micButton: {
width: 70,
height: 70,
borderRadius: 35,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
micActive: {
backgroundColor: '#FF3B30',
},
micIcon: { fontSize: 32 },
transcript: {
marginTop: 12,
fontSize: 16,
color: '#333',
maxWidth: '90%',
textAlign: 'center',
},
});Mood Tracking & Analytics
Add a mood journal feature:
// services/analytics.ts
import * as Analytics from 'expo-firebase-analytics';
export interface MoodEntry {
id: string;
mood: 'happy' | 'sad' | 'anxious' | 'calm' | 'neutral';
note: string;
timestamp: number;
}
export async function logMoodEntry(entry: MoodEntry): Promise<void> {
// Save to SQLite
db.transaction((tx) => {
tx.executeSql(
`INSERT INTO mood_entries (id, mood, note, timestamp)
VALUES (?, ?, ?, ?);`,
[entry.id, entry.mood, entry.note, entry.timestamp]
);
});
// Analytics
await Analytics.logEvent('mood_logged', {
mood: entry.mood,
hasNote: entry.note ? 'yes' : 'no',
});
}
export async function getMoodTrend(
days: number
): Promise<Map<string, number>> {
const oneWeekAgo = Date.now() - days * 24 * 60 * 60 * 1000;
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
`SELECT mood, COUNT(*) as count FROM mood_entries
WHERE timestamp > ? GROUP BY mood;`,
[oneWeekAgo],
(_, result) => {
const trend = new Map();
result.rows._array.forEach((row: any) => {
trend.set(row.mood, row.count);
});
resolve(trend);
},
(_, error) => reject(error)
);
});
});
}Offline Support with Background Sync
Enable users to chat offline and sync when back online:
// services/offlineSync.ts
export async function queueMessageForSync(
message: Message
): Promise<void> {
const queue = await AsyncStorage.getItem('syncQueue');
const current = queue ? JSON.parse(queue) : [];
current.push(message);
await AsyncStorage.setItem('syncQueue', JSON.stringify(current));
}
export async function syncQueuedMessages(): Promise<void> {
const queue = await AsyncStorage.getItem('syncQueue');
if (!queue) return;
const messages: Message[] = JSON.parse(queue);
try {
for (const msg of messages) {
await sendMessage(msg);
await db.transaction((tx) => {
tx.executeSql(`UPDATE messages SET synced = 1 WHERE id = ?;`, [
msg.id,
]);
});
}
await AsyncStorage.removeItem('syncQueue');
} catch (error) {
console.error('Sync failed:', error);
// Will retry on next online event
}
}
// In your app root:
export function useOnlineSync() {
useEffect(() => {
const subscription = NetInfo.addEventListener((state) => {
if (state.isConnected) {
syncQueuedMessages();
}
});
return () => subscription?.unsubscribe();
}, []);
}Subscriptions with RevenueCat
Implement in-app purchases:
npm install react-native-purchases
npx expo install react-native-purchases// services/purchases.ts
import Purchases, {
PurchasesPackage,
} from 'react-native-purchases';
export const ENTITLEMENTS = {
PREMIUM: 'premium',
VOICE: 'voice_premium',
};
export async function initializePurchases() {
await Purchases.configure({
apiKey: 'appl_YOUR_PUBLIC_SDK_KEY',
});
}
export async function getOfferings(): Promise<
PurchasesPackage[] | null
> {
try {
const offerings = await Purchases.getOfferings();
return offerings.current?.availablePackages ?? null;
} catch (error) {
console.error('Error fetching offerings:', error);
return null;
}
}
export async function purchasePackage(
pkg: PurchasesPackage
): Promise<boolean> {
try {
await Purchases.purchasePackage(pkg);
return true;
} catch (error) {
console.error('Purchase failed:', error);
return false;
}
}
export async function hasEntitlement(name: string): Promise<boolean> {
try {
const info = await Purchases.getCustomerInfo();
return (
info.activeSubscriptions.includes(name) ||
Object.keys(info.entitlements.all).includes(name)
);
} catch (error) {
console.error('Error checking entitlement:', error);
return false;
}
}App Store Submission Guidelines for AI Companions
Before submitting to the App Store, review Apple's policies:
Key requirements:
- Clear disclosure that this is an AI (not a real person)
- Privacy policy explaining data usage
- Parental controls recommended for users under 18
- No misleading health/mental health claims
- Terms of Service explaining limitations
- Proper handling of user data (encryption at rest/in transit)
- Clear opt-in for notifications
- Don't store payment info locally
Example privacy disclosure in your app:
// screens/PrivacyScreen.tsx
const PRIVACY_TEXT = `
This is an AI companion powered by Claude.
It is not a real person and cannot provide professional mental health care.
Data Handling:
- Conversations are stored locally on your device
- Your data is encrypted and never shared with third parties
- You can delete all data anytime in Settings
For support, contact: support@rorklab.com
`;Security Best Practices
- Encryption at Rest:
import * as SecureStore from 'expo-secure-store';
export async function storeSecure(key: string, value: string) {
await SecureStore.setItemAsync(key, value);
}
export async function retrieveSecure(key: string) {
return await SecureStore.getItemAsync(key);
}- API Key Rotation:
- Rotate tokens server-side monthly
- Invalidate old tokens after grace period
- Monitor for unusual usage patterns
- Data Privacy:
- GDPR: right to delete, data export
- CCPA: opt-out mechanisms
- Comply with App Store requirements
Monitoring & Analytics
Track important metrics:
// services/metrics.ts
export async function logUserSession(duration: number) {
await Analytics.logEvent('session_completed', {
duration_seconds: Math.round(duration),
});
}
export async function trackMessageCount(count: number) {
await Analytics.logEvent('daily_messages', {
count,
});
}
export async function trackConversion() {
await Analytics.logEvent('subscription_purchased');
}Looking back: Production Checklist
Before launching to production:
- ✅ Backend API with rate limiting
- ✅ Streaming responses
- ✅ Voice input/output
- ✅ Mood tracking
- ✅ Offline support
- ✅ RevenueCat integration
- ✅ App Store compliance
- ✅ Data encryption
- ✅ Privacy policy & ToS
- ✅ Analytics setup
- ✅ Error tracking (Sentry)
- ✅ Load testing
Your production AI companion is now ready to scale. Good luck shipping! 🚀