●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
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
After 13 years of solo app development — at the point where my catalog crossed 50 million cumulative downloads — I started noticing a specific moment in each of my apps: "adding one more feature here is going to break the UI." A camera button, then a mic button, then a text field. The instant all three appear, the screen stops working as a coherent surface.
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
✦Lessons from 13 years of solo app development with 50M+ cumulative downloads applied to multimodal AI design
✦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 → ...
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."
Next Step — Start With One Layer
We covered four layers in sequence — unified input, orchestration, UX flow, 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.