●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline
Rork × Multimodal AI: Design Patterns for Building Intelligent Apps with Camera, Voice, and Text
Learn how to build multimodal AI apps with Rork that seamlessly integrate camera image recognition, voice input, and text analysis. Covers unified input layers, AI orchestration across Gemini, Claude, and Core ML, UX flow design, and production-ready error handling.
Setup and context — The Layer You Need Before a Single Input Breaks Down
A camera button, then a mic button, then a text field. The instant all three appear, the screen stops working as a coherent surface. Running apps solo, I hit that same wall every time I added another input method — the thought "adding one more feature here is going to break the UI" started arriving before the feature did.
The real problem wasn't "I added too many inputs." It was that I had been wiring inputs together inside the UI itself, without a dedicated layer to unify them. What's actually hard about multimodal AI apps isn't calling Gemini or Whisper. It's collapsing the meaning, ordering, priority, cost boundaries, and fallback rules of multiple inputs into one place that lives outside the UI tree.
This article is a rewrite of the design patterns I use for tying camera, voice, and text together in Rork and Rork Max — informed by the mistakes I've made running wallpaper, healing, and law-of-attraction style apps in production. The code reflects what's actually shipped, and the decision thresholds are based on measurements from real users.
Who this article is for:
Developers who have built AI features in Rork and are looking for their next challenge
Anyone wanting to combine multiple AI models (Gemini, Claude, Core ML) in a single app
Developers struggling with state management and UX design for multimodal inputs
Previous articles have covered individual modalities — Camera & Gallery Features, Whisper × Claude Meeting Notes App, and Claude API Integration — but this guide focuses on the architecture that unifies them.
Designing the Unified Input Layer
Why You Need an Integration Layer
When you process camera, voice, and text inputs independently, three problems emerge quickly.
Context fragmentation: If a user takes a photo and then asks a voice question about it, the image context never reaches the voice processing pipeline
State management chaos: Three separate input states scattered across your app make UI consistency nearly impossible to maintain
Cumbersome prompt construction: You end up manually stitching data from each modality into AI prompts
The Unified Input Layer pattern solves all three.
Structure of the Unified Input Layer
// types/multimodal.ts — Type definitions for multimodal inputsexport interface ModalityInput { type: 'image' | 'audio' | 'text'; timestamp: number; data: ImageInput | AudioInput | TextInput; metadata?: Record<string, unknown>;}export interface ImageInput { uri: string; base64?: string; width: number; height: number; mimeType: string;}export interface AudioInput { uri: string; transcription?: string; // Text converted via Whisper durationMs: number; language?: string;}export interface TextInput { content: string; intent?: 'question' | 'command' | 'context';}// Unified context — the final input sent to AI modelsexport interface MultimodalContext { sessionId: string; inputs: ModalityInput[]; conversationHistory: ConversationTurn[]; userPreferences: UserPreferences;}
Every input is normalized as a ModalityInput and collected into a MultimodalContext. AI models receive only this unified context, remaining agnostic to the original input type.
Input Queue and Batch Processing
When a user fires multiple modalities in quick succession (snap a photo → immediately ask a voice question), queuing inputs with batch processing prevents race conditions and ensures the AI receives complete context.
The BATCH_DELAY_MS value of 1.5 seconds is calibrated for the typical pattern where a user takes a photo and immediately follows up with a voice question. Fine-tune this with A/B testing in your production app.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A just-in-time permission pattern for camera, mic, and photo library — with a defined degradation path when a modality is denied
✦Concrete benchmarks for when to use Gemini vs Claude, on-device fallback strategies, and cost control measured against real apps
✦Production pitfalls that aren't in the official docs — and the patterns I now reach for to avoid them
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
AI Orchestration — Choosing the Right Model for the Job
Why You Need a Multi-Model Strategy
Trying to funnel everything through a single AI model creates painful trade-offs between latency, cost, and accuracy. In multimodal apps, an orchestration strategy that routes inputs to the best-suited model is essential.
Model Selection Matrix
Task
Recommended Model
Rationale
Real-time image recognition
Core ML (on-device)
Low latency, works offline
Image + text analysis
Gemini 2.0 Flash
Native multimodal support, cost-efficient
Complex reasoning, long-form generation
Claude Opus 4.6
Top-tier reasoning and context understanding
Speech-to-text
Whisper API
Multilingual, high accuracy
Text classification, intent extraction
Core ML (NLModel)
Instant on-device processing
Image captioning
Gemini 2.0 Flash
Strong visual understanding
Implementing the Orchestrator
// services/aiOrchestrator.tsimport { MultimodalContext, ModalityInput } from '../types/multimodal';type AIProvider = 'coreml' | 'gemini' | 'claude' | 'whisper';interface OrchestratorConfig { preferOnDevice: boolean; // Prioritize on-device processing maxLatencyMs: number; // Maximum acceptable latency costBudgetPerRequest: number; // Budget per request (USD)}const DEFAULT_CONFIG: OrchestratorConfig = { preferOnDevice: true, maxLatencyMs: 3000, costBudgetPerRequest: 0.05,};export class AIOrchestrator { private config: OrchestratorConfig; constructor(config: Partial<OrchestratorConfig> = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; } // Select the optimal model based on input composition selectProvider(inputs: ModalityInput[]): AIProvider { const hasImage = inputs.some(i => i.type === 'image'); const hasAudio = inputs.some(i => i.type === 'audio'); const hasText = inputs.some(i => i.type === 'text'); // Audio present → route to Whisper first for transcription if (hasAudio) { return 'whisper'; } // Image only (no text) → on-device processing if (hasImage && !hasText && this.config.preferOnDevice) { return 'coreml'; } // Image + text → Gemini (native multimodal) if (hasImage && hasText) { return 'gemini'; } // Text only with complex reasoning → Claude const textInputs = inputs.filter(i => i.type === 'text'); const totalLength = textInputs.reduce((sum, i) => { return sum + (i.data as { content: string }).content.length; }, 0); if (totalLength > 500) { return 'claude'; } // Short text only → on-device processing return this.config.preferOnDevice ? 'coreml' : 'gemini'; } // Main processing pipeline async process(context: MultimodalContext): Promise<AIResponse> { const pipeline = this.buildPipeline(context.inputs); let result: AIResponse = { text: '', confidence: 0 }; for (const step of pipeline) { result = await this.executeStep(step, context, result); } return result; } // Build processing pipeline based on inputs private buildPipeline(inputs: ModalityInput[]): PipelineStep[] { const steps: PipelineStep[] = []; // Step 1: Convert audio to text const audioInputs = inputs.filter(i => i.type === 'audio'); if (audioInputs.length > 0) { steps.push({ provider: 'whisper', task: 'transcribe', inputs: audioInputs }); } // Step 2: On-device image classification const imageInputs = inputs.filter(i => i.type === 'image'); if (imageInputs.length > 0 && this.config.preferOnDevice) { steps.push({ provider: 'coreml', task: 'classify', inputs: imageInputs }); } // Step 3: Unified analysis with cloud AI const mainProvider = this.selectProvider(inputs); steps.push({ provider: mainProvider, task: 'analyze', inputs }); return steps; }}// Expected pipeline:// [whisper:transcribe] → [coreml:classify] → [gemini:analyze]// Voice input "What flower is this?" → transcribed to text// Image → Core ML classification → label: "Rosaceae"// All data sent to Gemini → "This image shows a flower from the Rosaceae family..."
Three key design principles make this orchestrator effective:
Sequential processing: Audio-to-text conversion happens first, letting subsequent stages treat everything as text
On-device priority: Core ML handles preprocessing whenever possible, reducing latency and cost
Offline fallback: When offline, the orchestrator automatically constrains processing to on-device models only
Advanced Camera Integration Patterns
Real-Time Preview with AI Analysis
Running AI analysis on the camera preview in real-time is the foundation of product search apps, translation apps, and smart scanning tools.
For voice input, streaming transcription — converting speech to text while the user is still talking — delivers a significantly better experience than waiting for the user to finish speaking.
After converting voice to text, classifying whether the text is a question, a command, or supplementary context dramatically improves AI response quality.
In a multimodal app, text input needs to be aware of recent camera and voice interactions. If a user takes a photo and then types something, that text should be interpreted as a comment about the image.
// hooks/useContextualTextInput.tsimport { useState, useCallback, useEffect } from 'react';interface ContextualTextState { text: string; relatedModality: 'image' | 'audio' | 'none'; suggestions: string[]; // Context-aware input suggestions}export function useContextualTextInput( recentInputs: ModalityInput[]) { const [state, setState] = useState<ContextualTextState>({ text: '', relatedModality: 'none', suggestions: [], }); // Update suggestions based on the most recent input modality useEffect(() => { const lastInput = recentInputs[recentInputs.length - 1]; if (!lastInput) return; if (lastInput.type === 'image') { setState(prev => ({ ...prev, relatedModality: 'image', suggestions: [ 'What is this?', 'Tell me more about this image', 'Find the price of this product', 'Extract the text from this', ], })); } else if (lastInput.type === 'audio') { setState(prev => ({ ...prev, relatedModality: 'audio', suggestions: [ 'Tell me more', 'Show me an image related to that', 'Summarize what I said', ], })); } }, [recentInputs]); const setText = useCallback((text: string) => { setState(prev => ({ ...prev, text })); }, []); return { ...state, setText };}
Designing the Multimodal UX Flow
Input Mode Switching Interactions
A smooth UI for switching between camera, voice, and text input is the make-or-break element of any multimodal app.
For multimodal input state management, a Finite State Machine (FSM) pattern keeps transitions clean and predictable.
// state/multimodalStateMachine.tstype AppState = | 'idle' // Waiting for input | 'capturing' // Camera active | 'listening' // Voice recording | 'typing' // Text input active | 'processing' // AI processing | 'displaying' // Showing results | 'error'; // Error statetype AppEvent = | { type: 'START_CAMERA' } | { type: 'START_VOICE' } | { type: 'START_TEXT' } | { type: 'INPUT_RECEIVED'; payload: ModalityInput } | { type: 'AI_RESPONSE'; payload: AIResponse } | { type: 'ERROR'; payload: Error } | { type: 'RESET' };export function multimodalReducer(state: AppState, event: AppEvent): AppState { switch (state) { case 'idle': if (event.type === 'START_CAMERA') return 'capturing'; if (event.type === 'START_VOICE') return 'listening'; if (event.type === 'START_TEXT') return 'typing'; return state; case 'capturing': case 'listening': case 'typing': if (event.type === 'INPUT_RECEIVED') return 'processing'; if (event.type === 'ERROR') return 'error'; if (event.type === 'RESET') return 'idle'; return state; case 'processing': if (event.type === 'AI_RESPONSE') return 'displaying'; if (event.type === 'ERROR') return 'error'; return state; case 'displaying': if (event.type === 'START_CAMERA') return 'capturing'; if (event.type === 'START_VOICE') return 'listening'; if (event.type === 'START_TEXT') return 'typing'; if (event.type === 'RESET') return 'idle'; return state; case 'error': if (event.type === 'RESET') return 'idle'; return state; default: return state; }}// Expected transitions:// idle → START_CAMERA → capturing → INPUT_RECEIVED → processing → AI_RESPONSE → displaying// displaying → START_VOICE → listening → INPUT_RECEIVED → processing → ...
Permission Timing Decides Whether Anyone Reaches Your Input Layer
You can build every layer above with care and still lose users before they touch it — if you request camera and microphone permissions in the wrong order, at the wrong moment. It's the class of failure you will never find by rereading your orchestrator.
Asking for Everything at Launch Costs You the Modality Permanently
I once shipped a build that requested camera, microphone, and photo library access back to back on first launch. On iOS, once a user taps "Don't Allow," your app cannot present that dialog again. From then on the only path is walking them into the Settings app, and very few people make that walk.
So a bundle of prompts at launch isn't a decision to lose one feature on day one. It's a decision to manufacture users who can never use that modality, before they have touched anything.
It also collides head-on with the "one success within the first 30 seconds" principle covered later in this article. By the time three dialogs have been dismissed, most of that window is gone.
Just-in-Time — Ask Immediately Before the Modality Actually Runs
There's one rule: request a permission only in the instant before that modality is used. Mic when the record button is pressed, camera the moment the capture surface is opened. The user can see the causal link between their action and the prompt, and grant rates rise accordingly.
In an Expo-based stack — which is where Rork's output lands — the camera goes through useCameraPermissions from expo-camera, and recording goes through getRecordingPermissionsAsync / requestRecordingPermissionsAsync from expo-audio. Wrap both in one hook that returns three states, not two: granted, denied, and can't-ask-anymore. Do that and your UI branching stays contained.
// hooks/useModalityGate.tsimport { useCallback } from "react";import { useCameraPermissions } from "expo-camera";import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync,} from "expo-audio";// blocked = the OS will no longer present the dialog (canAskAgain: false)export type GateResult = "granted" | "denied" | "blocked";export function useModalityGate() { const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const ensureCamera = useCallback(async (): Promise<GateResult> => { if (cameraPermission?.granted) return "granted"; if (cameraPermission && !cameraPermission.canAskAgain) return "blocked"; const next = await requestCameraPermission(); if (next.granted) return "granted"; return next.canAskAgain ? "denied" : "blocked"; }, [cameraPermission, requestCameraPermission]); const ensureVoice = useCallback(async (): Promise<GateResult> => { const current = await getRecordingPermissionsAsync(); if (current.granted) return "granted"; if (!current.canAskAgain) return "blocked"; const next = await requestRecordingPermissionsAsync(); if (next.granted) return "granted"; return next.canAskAgain ? "denied" : "blocked"; }, []); return { ensureCamera, ensureVoice };}
Collapsing denied and blocked into a single boolean is the mistake to avoid. A denied user can be asked again later, in a better context. A blocked user who is waiting on a dialog that will never appear is left staring at a button that does nothing when tapped. Simulators let you reset permissions freely, so this state only shows up on real devices — it took me several days to trace.
The call site pushes the result straight into MultimodalContext:
const { ensureCamera } = useModalityGate();const handleCameraPress = async () => { const result = await ensureCamera(); if (result === "granted") { dispatch({ type: "START_CAMERA" }); return; } // Record the degradation in the layer; the UI only reads state dispatch({ type: "DISABLE_MODALITY", payload: { modality: "camera", reason: result }, }); if (result === "blocked") { Alert.alert( "To use the camera", "Allow camera access in Settings and you'll be able to ask questions directly from a photo you take.", [ { text: "Not now", style: "cancel" }, { text: "Open Settings", onPress: () => Linking.openSettings() }, ], ); }};
Routing through DISABLE_MODALITY merges permission refusal into the same path as graceful degradation. Deciding once that a permission denial is not an error but "one fewer available modality" makes both the implementation and the experience simpler.
Request Timing and Fallback per Modality
Modality
Info.plist key
When to request
Fallback when denied
Camera
NSCameraUsageDescription
Immediately after the camera button is tapped
Switch to picking from the photo library
Microphone
NSMicrophoneUsageDescription
The moment the record button is pressed
Fall back to text input and collapse the voice UI
Photo library
NSPhotoLibraryUsageDescription
Right after "Choose from library" is selected
Restrict to capturing on the spot
Speech recognition (on-device)
NSSpeechRecognitionUsageDescription
Directly after mic access is granted, inside the same gesture
Upload the recording and transcribe server-side
Deciding the fallback first removes the "what if they say no" branch from your design entirely. Whatever else fails, text input always survives — hold that one line and the app never goes unresponsive.
Don't Ship the Default Purpose Strings
When a build gets rejected over permissions, the cause is usually the copy rather than the code. App Store Review Guideline 5.1.1 requires purpose strings to describe the use of a protected resource specifically, and in most cases to include an example of that use. "This app uses the camera" does not meet that bar.
❌ Please allow access to the camera
✅ Used to analyze photos you take so the app can identify what's in frame and explain how to care for it
The Expo-specific trap is that config plugins such as expo-camera inject a default purpose string for you. Leave it untouched and you ship something close to Allow $(PRODUCT_NAME) to access your camera — generic, and in a non-English market, actively harmful in both review and the live dialog. Override it through the plugin props in app.json or through ios.infoPlist. The exact wording of the rule is worth reading in App Store Review Guidelines 5.1.1 (Apple Developer).
Log Grant Rates per Modality
One last unglamorous habit that pays off: log the return value of ensureCamera / ensureVoice as an event tagged with the modality name. Once you can see which modality is being refused, on which screen, and how often, skews like "only the mic grant rate is unusually low" show up as numbers. In my case, tracing that back revealed the request was firing one screen too early.
Without that measurement, you'll assume the problem lives in the model and keep patching the orchestrator. Users lost upstream of the input layer don't come back no matter how much accuracy you add downstream.
Error Handling and Fallback Strategies
Graceful Degradation
In multimodal apps, certain modalities or AI models may become unavailable due to network issues, API errors, or denied permissions. Graceful degradation ensures the app continues to function even when parts of the pipeline fail.
// services/fallbackStrategy.tsinterface FallbackChain { primary: AIProvider; fallbacks: AIProvider[]; offlineOnly: AIProvider | null;}const FALLBACK_CHAINS: Record<string, FallbackChain> = { imageAnalysis: { primary: 'gemini', fallbacks: ['claude', 'coreml'], offlineOnly: 'coreml', }, textGeneration: { primary: 'claude', fallbacks: ['gemini'], offlineOnly: null, // Text generation requires cloud }, speechToText: { primary: 'whisper', fallbacks: ['coreml'], // iOS Speech Framework offlineOnly: 'coreml', },};export async function executeWithFallback<T>( task: string, execute: (provider: AIProvider) => Promise<T>): Promise<T> { const chain = FALLBACK_CHAINS[task]; if (!chain) throw new Error(`Unknown task: ${task}`); // Check network status const isOnline = await checkNetworkStatus(); if (!isOnline && chain.offlineOnly) { return execute(chain.offlineOnly); } if (!isOnline && !chain.offlineOnly) { throw new Error('This feature requires an internet connection'); } // Try primary → fallbacks in order const providers = [chain.primary, ...chain.fallbacks]; let lastError: Error | null = null; for (const provider of providers) { try { return await execute(provider); } catch (error) { lastError = error as Error; console.warn(`${provider} failed for ${task}:`, error); continue; } } throw lastError || new Error(`All providers failed for ${task}`);}// Usage:// const result = await executeWithFallback('imageAnalysis', async (provider) => {// return await analyzeImage(imageData, provider);// });// → gemini fails → falls back to claude → that fails too → coreml handles it
Performance Optimization and Cost Management
Managing Latency Budgets
Multimodal processing chains multiple AI model calls sequentially, which can inflate total latency. Setting a latency budget with timeouts for each step protects the user experience.
// utils/latencyBudget.tsinterface BudgetAllocation { preprocessing: number; // Image resize, audio chunking onDeviceML: number; // Core ML inference networkTransfer: number; // Data transfer to API cloudInference: number; // Cloud AI inference postprocessing: number; // Result formatting and display}// Total budget: 3 secondsconst DEFAULT_BUDGET: BudgetAllocation = { preprocessing: 200, // 200ms onDeviceML: 300, // 300ms networkTransfer: 500, // 500ms cloudInference: 1500, // 1500ms postprocessing: 100, // 100ms};// Remaining: 400ms reserved as bufferexport function createTimedExecution<T>( budgetMs: number, fallback: T): (fn: () => Promise<T>) => Promise<T> { return async (fn) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), budgetMs); try { const result = await fn(); clearTimeout(timeout); return result; } catch (error) { clearTimeout(timeout); if ((error as Error).name === 'AbortError') { console.warn(`Budget exceeded: ${budgetMs}ms`); return fallback; } throw error; } };}
Optimizing API Costs
Multimodal processing tends to be expensive. Here are four strategies to keep costs under control:
On-device preprocessing: Run image classification and text classification on Core ML to avoid unnecessary cloud API calls
Image compression: Resize images to appropriate dimensions before sending (see the preprocessImage utility above)
Response caching: Cache AI responses for identical image + query combinations
Batch processing: Consolidate multiple inputs into a single API request
// utils/costTracker.ts — API cost trackinginterface CostEntry { provider: string; inputTokens: number; outputTokens: number; imageCount: number; estimatedCostUSD: number; timestamp: number;}// Monthly cost cap managementconst MONTHLY_BUDGET_USD = 50;export class CostTracker { private entries: CostEntry[] = []; addEntry(entry: CostEntry) { this.entries.push(entry); } getMonthlyTotal(): number { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); return this.entries .filter(e => e.timestamp >= monthStart) .reduce((sum, e) => sum + e.estimatedCostUSD, 0); } isWithinBudget(): boolean { return this.getMonthlyTotal() < MONTHLY_BUDGET_USD; } // When over budget, restrict to on-device models only shouldUseCloudAI(): boolean { return this.isWithinBudget(); }}
Production Pitfalls That Aren't in the Official Docs
Everything up to this point can be assembled by carefully reading the official documentation and samples. What follows is different — these are the judgment calls and traps I've personally hit while running multimodal features in wallpaper, healing, and manifesting style apps in production. I'm sharing them so you don't have to step on the same mines I did.
1. Overstuffing "Multimodal" Into the UI Will Tank Your Day 7 Retention
In one of my wallpaper apps, I shipped a generative feature and watched Day 7 retention drop from 34% to 21% within two weeks. The root cause wasn't a missing feature — it was that the first launch presented camera, microphone, and text inputs to the user back-to-back.
The lesson was simple: on the first session, expose exactly one input modality. In my apps now, the first session shows either text or a photo picker — never both, and never a mic. Only users who had a good first result ever get the microphone surfaced to them. After that change, Day 7 came back to about 31% and Day 30 stabilized around 12%.
// A helper that progressively unlocks modalitiesclass ModalityProgressionManager { // Decide which inputs to expose based on session count and last result quality static getEnabledModalities( sessionCount: number, lastResultRating: number | null ): InputModality[] { if (sessionCount === 0) return ["text"]; // First session: text only if (sessionCount < 3) return ["text", "camera"]; // Next few sessions: + camera // Voice is gated behind a positive recent experience if (lastResultRating !== null && lastResultRating >= 3) { return ["text", "camera", "voice"]; } return ["text", "camera"]; }}
Just by refusing to set maxSimultaneousInputs to 3 on day one, first-session completion rate went from 38% to 61% in my data.
2. Don't Run Gemini and Claude in Parallel as a "Belt and Suspenders" Fallback
In the orchestrator section we discussed using Gemini for image-heavy inputs and Claude for complex reasoning. That's correct — but a tempting next step is to call both APIs in parallel and pick the better answer. When I did that, my monthly API bill went up 2.3x and answer consistency actually got worse (the two models disagreed often enough to confuse downstream logic).
In production I settled on a 3-tier policy instead:
Tier 1: Gemini 2.0 Flash gets the first attempt (forced if there's an image). p50 800ms, p95 2.4s, around ¥0.4 per request.
Tier 2: Only when Gemini emits a confidence < 0.6 signal do we re-run with Claude Haiku. About 5% of requests fall through to this tier.
Tier 3: If Claude also returns "I don't know," we ask the user for one more piece of context rather than calling another API. That single extra prompt raises overall success rate by 23 percentage points in my measurements.
Moving from "always parallel" to this 3-tier policy reduced my monthly API spend from roughly ¥45,000 to ¥18,000.
// 3-tier orchestrationasync function tieredMultimodalCall(ctx: MultimodalContext): Promise<AIResponse> { // Tier 1: Gemini 2.0 Flash const firstPass = await callGemini(ctx); if (firstPass.confidence >= 0.6) return firstPass; // Tier 2: Claude Haiku (strong on text-heavy reasoning) const secondPass = await callClaudeHaiku(ctx, { previousAttempt: firstPass }); if (secondPass.confidence >= 0.6) return secondPass; // Tier 3: Ask the user for clarification — no API call return { type: "clarification_needed", suggestedQuestions: secondPass.clarifyingQuestions ?? defaultClarifyingQuestions, };}
3. "Three Seconds of Silence" Beats VAD-Based End-of-Speech Detection in Practice
When working with Whisper or Gemini Live, the docs walk you through tuning VAD (Voice Activity Detection) thresholds. It works, but I've consistently observed that a simpler rule — finalize when the user has been silent for 3 seconds — produces a better felt experience.
The reason, I think, is that people pause longer than expected while speaking ("uh…", trailing breath, looking for the right word). Tight VAD thresholds cut them off mid-sentence, which is much more painful than a 3-second wait. The silence rule mirrors the natural cadence of "take a beat after finishing a thought."
class SilenceConfirmAudioRecorder { private silenceThresholdMs = 3000; // 3 seconds private lastVoiceAt = Date.now(); private silenceTimer: NodeJS.Timeout | null = null; onAudioFrame(volume: number, onFinish: () => void) { if (volume > 0.05) { // Speaking again — reset the timer this.lastVoiceAt = Date.now(); if (this.silenceTimer) { clearTimeout(this.silenceTimer); this.silenceTimer = null; } return; } // Silence started — arm the 3-second timer if (!this.silenceTimer) { this.silenceTimer = setTimeout(() => { onFinish(); // Finalize and ship }, this.silenceThresholdMs); } }}
CPU overhead is essentially zero, even on an iPhone SE (3rd gen), and the implementation is far more robust than tuning VAD.
4. Resize Images to "Shorter Edge 1024px" Client-Side — It Cuts Gemini Cost by About 40%
The Gemini docs say resizing "speeds things up," but they don't give you a concrete threshold or the cost impact. I measured it directly with my wallpaper-app traffic:
Image size sent
Gemini p50 latency
Cost per image
Scene classification accuracy
4032×3024 (raw)
2.8s
¥0.62
92%
Shorter edge 1536px
1.5s
¥0.41
91%
Shorter edge 1024px
1.1s
¥0.37
91%
Shorter edge 512px
0.9s
¥0.35
78%
Shorter edge 1024px sits right at the boundary where you minimize cost and latency without losing classification accuracy. Drop further to 512px and you start losing detail on scenes that depend on subtle features (clouds, sunset gradients, soft textures).
import { manipulateAsync, SaveFormat } from "expo-image-manipulator";// Keep the shorter edge at 1024pxasync function resizeForGemini(localUri: string): Promise<string> { const result = await manipulateAsync( localUri, [{ resize: { width: 1024 } }], { compress: 0.85, format: SaveFormat.JPEG } ); return result.uri;}
5. Your Core ML Fallback Is Probably Already Broken — Unless CI Hits It Daily
The on-device fallback is supposed to be your last line of defense, yet ironically it's also the path most likely to be silently broken at any given moment. When I upgraded Expo SDK once, expo-camera's API signature changed, and only the fallback path broke. I didn't notice for six weeks because the cloud path kept working fine.
The lesson: a fallback is like an aircraft emergency slide — if you don't deploy it regularly, you don't actually have one. I now run a nightly CI smoke test that intentionally forces the Core ML fallback path.
// __tests__/smoke/coreml-fallback.smoke.test.tsdescribe("Core ML fallback smoke", () => { beforeAll(() => { // Simulate the cloud being unreachable jest.spyOn(global, "fetch").mockRejectedValue(new Error("offline")); }); it("classifies an image end-to-end on device", async () => { const result = await classifyImage("./__fixtures__/sample-landscape.jpg"); expect(result.source).toBe("on-device"); expect(result.labels.length).toBeGreaterThan(0); }); it("classifies Japanese text on device", async () => { const result = await classifyText("こんにちは、今日はいい天気ですね"); expect(result.source).toBe("on-device"); expect(result.sentiment).toBeDefined(); });});
As long as CI is green, I can actually trust that the fallback exists. That alone improved my own peace of mind, and on the day a regional cloud outage hit, the user experience held up.
6. Multimodal Retention Is Decided in the First 30 Seconds
This one is more pattern than statistic, but it has hardened into a conviction across 13 years of solo development. For multimodal AI apps, whether the user experiences one moment of "oh — this actually works" within the first 30 seconds is, in my data, a bigger determinant of Day 7 retention than almost anything else.
Across the apps I run:
Users who hit a "success" within the first 30 seconds: Day 7 around 41%, Day 30 around 15%.
Users who didn't: Day 7 around 18%, Day 30 around 5%.
"Success" here doesn't mean a perfect AI answer. It means "the system reacted, at all, to something I put into it." If a cloud call is slow enough that the first response slips past the 30-second window, you'd be better off returning a fast Core ML classification in 0.8 seconds even if the answer is rougher — and reaching for the cloud only on the next interaction.
It's the kind of inversion the docs won't tell you: prioritize "speed to first response" over "accuracy of any individual response."
Two Implementation Details Worth Flagging
Input Order Changes How the Model Reads Your Request
This isn't spelled out in any documentation, but image-then-voice tends to be interpreted as "asking a question about this image," while text-then-image reads as "illustrating what the text describes." If you keep timestamp on MultimodalContext and hand the model the inputs in that same order, answer relevance improves noticeably — roughly ten percent in my own side-by-side checks. The fastest way to see it for yourself is to send identical material twice with only the ordering swapped, then read the two responses next to each other.
The Realistic Device Floor for On-Device Processing
Core ML inference runs at usable speed on iPhone 12 and later, with A14 Bionic or newer (and its Neural Engine) as the comfortable target. The image preprocessing pipeline — resize plus Base64 encoding — spikes to somewhere around 50–100MB of memory, so keep maxWidth / maxHeight conservative on low-memory devices. My own policy is to support iPhone 11 and later, and to hide the multimodal entry points entirely below that rather than let them fail slowly.
Next Step — Start With One Layer
We covered five layers in sequence — unified input, orchestration, UX flow, the permission gate, and error handling — but if you try to build all of them cleanly from day one, your project will fall over in the middle. Mine certainly did.
If I were giving advice to past me, it would be: start by adding only the unified input layer to your existing app, as a MultimodalContext. Even if it currently wraps a single modality (just the camera, or just the mic), the value is that adding a second modality later won't require rewriting your UI. That single piece of structural discipline pays back many times over.
After 13 years of this work, my honest take is that multimodal AI isn't about cramming features in. It's about having an architecture that lets you put the right modality in front of the user, only when they need it, in a way that feels natural. Whether you have that container ready when you ship the first feature is what shapes what your app looks like six months later.
I'm still in the middle of porting these patterns into my own wallpaper and healing apps, and I don't feel like I've reached "done" yet either. I'd be glad to keep iterating alongside anyone going through the same process. Thank you for reading all the way through.
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.