Here's something I noticed when browsing the App Store a few months back: apps in the "interview preparation" category are quietly generating solid subscription revenue. Names like "Mock Interview AI" and "Interview Coach Pro" appear in the lifestyle and education charts, with price points between $8–$25/month, and they're not tiny operations.
The market makes sense when you think about it. Job searching is a high-stakes activity where people genuinely want to improve. The motivation to practice is there — what's historically been missing is an accessible, on-demand way to get feedback on your actual answers. AI changes that equation.
This guide walks through building a production-ready AI interview coach app with Rork Max. I'll cover the full pipeline — voice recording, transcription via Whisper, evaluation via Claude 4, session storage, and a freemium monetization model — with working code for each component.
Why This App Category Makes Sense for Indie Developers
Before diving into implementation, it's worth understanding what makes this a good market to enter.
Recurring need with seasonal spikes. Job searching happens throughout the year, with peaks during hiring seasons. Unlike a one-time utility app, interview prep is something users return to over weeks or months as they actively search.
High willingness to pay. If someone lands a job that pays $10,000 more per year, they'll happily look back at a $20/month subscription as one of the best ROIs of their life. The value proposition is clear and the payback period is obvious.
Underserved in non-English markets. High-quality interview prep apps designed specifically for Japanese-style interviews, German corporate culture, or Korean hiring processes are genuinely rare. Localizing this concept is itself a competitive advantage.
Technical moat is real but buildable. The combination of speech recognition + AI evaluation + progress tracking is sophisticated enough to be defensible, but entirely achievable with Rork Max and the right APIs.
Architecture: The Three-Step Pipeline
The core of this app is a three-step pipeline:
Step 1: Voice Recording The user reads a question (displayed as text, or optionally played via TTS), records their answer via microphone, and the audio file is captured locally.
Step 2: Transcription The audio is sent to OpenAI's Whisper API, which converts it to text. Whisper handles accents, filler words ("um," "uh"), and conversational speech well — which is actually useful information for interview feedback.
Step 3: AI Evaluation The transcript is sent to Claude 4 via a Cloudflare Workers backend. Claude returns a structured evaluation: numerical scores on four dimensions, specific strengths, areas for improvement, a rewritten model answer, and a one-line tip.
Results and session history are stored in Supabase.
[Rork Max App]
↓ audio file (m4a/mp4)
[Cloudflare Workers]
↓ forward
[Whisper API (OpenAI)]
↓ transcript
[Claude 4 API (Anthropic)]
↓ structured JSON evaluation
[Supabase] ← session history stored
↓
[Rork Max App] ← feedback UI rendered
Running the API calls through a Cloudflare Workers backend is non-negotiable for production. Embedding API keys in a mobile app violates both security best practices and App Store policies. Cloudflare Workers handles this with about 50 lines of code and has a generous free tier.
Step 1: Voice Recording with expo-av
Prompt Rork Max to scaffold the recording screen:
"Create a SwiftUI recording screen where tapping a microphone button starts audio recording and tapping again stops it. While recording, the button turns red and a timer shows elapsed time. After stopping, show a waveform visualization and an 'Analyze Answer' button. Use expo-av for the recording implementation."
Rork Max will generate the visual structure. Add this hook to handle the actual recording logic:
// hooks/useAudioRecorder.ts
import { Audio } from 'expo-av';
import { useState, useRef } from 'react';
export function useAudioRecorder() {
const [isRecording, setIsRecording] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUri, setAudioUri] = useState<string | null>(null);
const recording = useRef<Audio.Recording | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const startRecording = async () => {
try {
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
throw new Error('Microphone permission is required');
}
// Set recording mode — prevents conflict with background audio
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
const { recording: newRecording } = await Audio.Recording.createAsync(
// HIGH_QUALITY outputs mp4 on both iOS and Android — best Whisper compatibility
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recording.current = newRecording;
setIsRecording(true);
setRecordingDuration(0);
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (error) {
console.error('Recording start error:', error);
throw error;
}
};
const stopRecording = async (): Promise<string> => {
if (!recording.current) throw new Error('Not currently recording');
try {
if (timerRef.current) clearInterval(timerRef.current);
await recording.current.stopAndUnloadAsync();
// IMPORTANT: Reset audio mode after recording
// Skipping this causes interference with other apps' audio playback
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
const uri = recording.current.getURI();
if (!uri) throw new Error('Recording file not found');
setAudioUri(uri);
setIsRecording(false);
recording.current = null;
return uri;
} catch (error) {
console.error('Recording stop error:', error);
throw error;
}
};
return { isRecording, recordingDuration, audioUri, startRecording, stopRecording };
}Enforce a 3-minute maximum recording time. Longer responses are problematic from both a UX and interview coaching perspective, and Whisper costs scale with duration.
Step 2: Cloudflare Workers Backend — Whisper + Claude Evaluation
This is the most critical component. The Workers function receives an audio file, runs it through Whisper, then passes the transcript to Claude for evaluation.
// Cloudflare Workers: src/index.ts
interface Env {
OPENAI_API_KEY: string;
ANTHROPIC_API_KEY: string;
}
interface EvaluationResult {
transcription: string;
scores: {
relevance: number; // How directly the answer addresses the question (0-10)
specificity: number; // Use of concrete examples, numbers, outcomes (0-10)
structure: number; // Logical flow — STAR method or equivalent (0-10)
language: number; // Vocabulary, clarity, filler word frequency (0-10)
};
overall: number; // Weighted composite score (0-100)
strengths: string[];
improvements: string[];
rewrittenAnswer: string; // Claude's improved version of the answer
tips: string; // One-line coaching tip
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const formData = await request.formData();
const audioFile = formData.get('audio') as File;
const questionText = formData.get('question') as string;
const industry = (formData.get('industry') as string) ?? 'general';
const level = (formData.get('level') as string) ?? 'mid-level';
if (!audioFile || !questionText) {
return new Response(
JSON.stringify({ error: 'Audio file and question text are required' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step A: Transcription via Whisper
const whisperFormData = new FormData();
whisperFormData.append('file', audioFile, 'recording.mp4');
whisperFormData.append('model', 'whisper-1');
whisperFormData.append('response_format', 'json');
const whisperResponse = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` },
body: whisperFormData,
});
if (!whisperResponse.ok) {
throw new Error(`Whisper API error: ${await whisperResponse.text()}`);
}
const { text: transcription } = await whisperResponse.json() as { text: string };
if (!transcription || transcription.trim().length < 15) {
return new Response(
JSON.stringify({
error: 'The recording was too short or could not be recognized. Please try again.'
}),
{ status: 422, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step B: Evaluation via Claude 4
const claudeResponse = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 1500,
messages: [{
role: 'user',
content: buildEvaluationPrompt(questionText, transcription, industry, level)
}],
}),
});
if (!claudeResponse.ok) {
throw new Error(`Claude API error: ${claudeResponse.status}`);
}
const claudeData = await claudeResponse.json() as {
content: Array<{ text: string }>
};
const rawText = claudeData.content[0]?.text ?? '';
// Claude occasionally adds preamble text — extract JSON block defensively
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('Failed to parse evaluation JSON from Claude response');
}
const evaluation = JSON.parse(jsonMatch[0]) as EvaluationResult;
evaluation.transcription = transcription;
return new Response(JSON.stringify(evaluation), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Processing error:', error);
return new Response(
JSON.stringify({ error: 'An error occurred during evaluation. Please try again.' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}
};
function buildEvaluationPrompt(
question: string,
answer: string,
industry: string,
level: string
): string {
return `You are an expert interview coach specializing in ${industry} industry hiring at the ${level} level.
Evaluate the following interview answer and return ONLY valid JSON — no preamble, no explanation.
QUESTION: ${question}
CANDIDATE ANSWER (from voice transcript): ${answer}
Return this exact JSON structure:
{
"scores": {
"relevance": <0-10, how directly this addresses the question>,
"specificity": <0-10, use of concrete examples/numbers/outcomes>,
"structure": <0-10, logical flow, STAR method adherence>,
"language": <0-10, vocabulary, clarity, minimal filler words>
},
"overall": <0-100 weighted composite>,
"strengths": ["specific strength 1", "specific strength 2"],
"improvements": ["specific improvement 1", "specific improvement 2", "specific improvement 3"],
"rewrittenAnswer": "An improved version of this answer in ~100 words that demonstrates best practices",
"tips": "One actionable coaching tip for this specific candidate"
}
Be direct and honest. Vague feedback ("good job!") is not useful. Note that this is a voice transcript, so filler words ("um", "uh", "like") appear in the text — factor this into the language score.`;
}The evaluation prompt design is the core of this product's value. Including industry and seniority level parameters makes the feedback significantly more relevant — a junior developer should not be evaluated by the same standards as a VP of Engineering.
Step 3: Session Storage and Progress Tracking
Single-session evaluation is useful. Multi-session progress tracking is what makes users stay subscribed.
// lib/session-manager.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
);
export interface PracticeSession {
id: string;
user_id: string;
question_id: string;
question_text: string;
transcription: string;
scores: {
relevance: number;
specificity: number;
structure: number;
language: number;
};
overall: number;
strengths: string[];
improvements: string[];
rewritten_answer: string;
tips: string;
industry: string;
level: string;
created_at: string;
}
export async function saveSession(
userId: string,
questionText: string,
questionId: string,
evaluation: Omit<PracticeSession, 'id' | 'user_id' | 'created_at'>
): Promise<PracticeSession> {
const { data, error } = await supabase
.from('practice_sessions')
.insert({
user_id: userId,
question_id: questionId,
question_text: questionText,
transcription: evaluation.transcription,
scores: evaluation.scores,
overall: evaluation.overall,
strengths: evaluation.strengths,
improvements: evaluation.improvements,
rewritten_answer: evaluation.rewritten_answer,
tips: evaluation.tips,
industry: evaluation.industry,
level: evaluation.level,
})
.select()
.single();
if (error) {
console.error('Session save error:', error);
throw new Error('Failed to save practice session');
}
return data as PracticeSession;
}
export async function getUserProgress(
userId: string,
questionId?: string,
limit = 20
): Promise<PracticeSession[]> {
let query = supabase
.from('practice_sessions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit);
if (questionId) {
query = query.eq('question_id', questionId);
}
const { data, error } = await query;
if (error) throw new Error(`Failed to fetch progress: ${error.message}`);
return (data ?? []) as PracticeSession[];
}
// Calculate improvement trend across sessions for a question
export function calculateImprovementTrend(sessions: PracticeSession[]): {
trend: 'improving' | 'stable' | 'declining';
percentChange: number;
} {
if (sessions.length < 2) return { trend: 'stable', percentChange: 0 };
const recent = sessions.slice(0, Math.min(3, sessions.length));
const earlier = sessions.slice(-Math.min(3, sessions.length));
const recentAvg = recent.reduce((sum, s) => sum + s.overall, 0) / recent.length;
const earlierAvg = earlier.reduce((sum, s) => sum + s.overall, 0) / earlier.length;
const percentChange = ((recentAvg - earlierAvg) / earlierAvg) * 100;
return {
trend: percentChange > 5 ? 'improving' : percentChange < -5 ? 'declining' : 'stable',
percentChange: Math.round(percentChange),
};
}When you prompt Rork Max to generate a "progress dashboard with a line chart showing score history," it will typically output Victory Native chart code with a clean layout. Wire in calculateImprovementTrend to show badges like "+15% since last week" — that positive reinforcement drives session frequency.
Freemium and Subscription Design
The freemium structure for this category is fairly well established. Here's what I'd recommend:
Free plan:
- 3 practice sessions per day
- Basic evaluation (scores + 2 improvement points)
- 7-day history
Premium ($9.99/month):
- Unlimited sessions
- Full evaluation (rewritten model answer + coaching tip)
- Industry-specific question banks (Tech, Finance, Consulting, Healthcare, etc.)
- Complete history and detailed progress charts
- Monthly weakness analysis report
The critical design decision: show free users the score and strengths, but blur the rewritten answer and coaching tip behind a paywall. The psychological "I can almost see it" pattern converts well for a self-improvement product. Users who can see their score is a 6/10 but can't see how to get to a 9/10 are motivated to upgrade.
// components/EvaluationResultScreen.tsx (excerpt)
import { useRevenueCat } from '../hooks/useRevenueCat';
export function EvaluationResultScreen({ result }: { result: EvaluationResult }) {
const { isPremium } = useRevenueCat();
return (
<ScrollView>
{/* Always visible */}
<ScoreCard scores={result.scores} overall={result.overall} />
<StrengthsList strengths={result.strengths} />
{/* Premium-gated */}
{isPremium ? (
<>
<ImprovementsList improvements={result.improvements} />
<RewrittenAnswer text={result.rewrittenAnswer} />
<CoachingTip tip={result.tips} />
</>
) : (
<PremiumPaywallTeaser
preview={result.improvements[0]} // Show the first improvement point
onUpgrade={() => Purchases.presentOfferingIfEligibleForIntroOffer()}
/>
)}
</ScrollView>
);
}Common Pitfalls
Pitfall 1: Not resetting iOS audio mode after recording
After calling stopRecording, you must call Audio.setAudioModeAsync({ allowsRecordingIOS: false }). Omitting this causes the app to interfere with other apps' audio playback on iOS. The code above includes this, but Rork Max-generated audio code often doesn't.
Pitfall 2: Android audio format incompatibility with Whisper
The default recording format on Android is AMR-NB, which Whisper may reject. Explicitly use Audio.RecordingOptionsPresets.HIGH_QUALITY — this outputs MP4/AAC on both platforms, which Whisper handles reliably.
Pitfall 3: Claude returning non-JSON responses
Claude follows JSON instructions correctly most of the time, but occasionally prepends explanation text. Always use rawText.match(/\{[\s\S]*\}/) to extract the JSON block defensively. If you skip this, unexpected responses will crash your evaluation pipeline.
Pitfall 4: Discriminatory interview questions App Store review teams flag apps that include questions related to age, marital status, nationality, or religion in hiring contexts. Even if such questions exist in some real-world interviews, avoid including them in your question bank. It's both the right thing to do and avoids review complications.
Pitfall 5: Uncleaned audio files accumulating on device
After evaluation completes, delete the local audio file with FileSystem.deleteAsync(audioUri). If you want to offer audio playback as a premium feature, upload to Supabase Storage before deleting locally and store the URL in the session record.
Growth Strategy After Launch
Once the core experience is working, the highest-leverage next move is usually improving question quality and expanding the question bank. Users who practice the same 20 questions repeatedly will churn. A rotating library of fresh, industry-specific questions is what keeps subscriptions active.
The second lever is seasonality. In markets with defined hiring seasons (Japan's spring graduation hiring, fall internship season in the US), targeted campaigns and App Store featuring requests aligned with these periods can generate outsized installs.
The longer-term opportunity is B2B. Corporate clients — for onboarding training, internal promotion prep, or bootcamp curricula — will pay significantly more per seat than individual subscribers, and they churn far less. Getting your first B2B client often comes from your existing B2C users who happen to work in L&D departments.
Start with the consumer subscription product, validate the evaluation quality, build the question bank, and revisit B2B once you have 500+ active subscribers as social proof.
The first step today: get the Cloudflare Workers backend deployed and test the Whisper → Claude pipeline with a real recording. The pipeline working is the hardest part. Everything else — UI polish, question banks, subscription gates — can be layered in over time.