RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/App Dev
App Dev/2026-03-15Advanced

Production AI Companion Apps: Streaming, Voice & Monetization

Build a production-grade AI companion with streaming responses, voice I/O, mood tracking, offline support, and subscription monetization. App Store submission guidelines included.

Expo149React Native209AI Companion2Streaming2VoiceRevenueCat28Advanced4Production10

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:

  1. Backend Service — Secure API key handling, request validation, rate limiting
  2. Streaming Engine — Real-time text display as the AI responds
  3. Voice Layer — Speech-to-text and text-to-speech
  4. Analytics — User behavior, retention, subscription metrics
  5. Monetization — RevenueCat for subscriptions
  6. Offline Mode — SQLite-based offline responses
  7. 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

  1. 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);
}
  1. API Key Rotation:
  • Rotate tokens server-side monthly
  • Invalidate old tokens after grace period
  • Monitor for unusual usage patterns
  1. 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! 🚀

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-07-14
Long-Press Context Menus for a Gallery Item in a Rork Expo App
Long-pressing a wallpaper card does nothing, yet iOS users expect a preview and a menu. From why Pressable alone falls short, to a native context menu with zeego, resolving the scroll-vs-long-press conflict, wiring up save and share, and a custom overlay fallback for Android — all with working code.
App Dev2026-07-07
Laying Out Variable-Height Images in Two Columns: A Masonry Wallpaper Gallery in a Rork Expo App
From why numColumns cannot pack variable-aspect images cleanly, to a dependency-free column-balancing algorithm, to keeping virtualization with FlashList masonry and a pragmatic no-dependency fallback, building a wallpaper gallery with real code.
App Dev2026-07-05
Building a One-Time Code Field in Expo — SMS Autofill and Segmented Display Together
A six-digit verification screen looks trivial, but once you account for SMS autofill, pasting, and deleting one digit at a time, it needs real care. Here is how to nail the iOS and Android autofill first, then build a segmented look on top of a single TextInput that does not break.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →