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/AI Models
AI Models/2026-05-05Advanced

Build an AI Interview Coach App with Rork Max: Voice Recording, Claude Evaluation & Progress Tracking

A complete guide to building a production-ready AI interview coach app with Rork Max — covering voice recording, Whisper transcription, Claude 4 evaluation, session tracking, and subscription monetization.

AI Interview CoachRork Max229Claude API11Voice Recognition2Whisper4CareerSubscription23React Native209

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.

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

AI Models2026-04-08
Building an Immersive AI Language Learning App with Rork — Whisper Speech Recognition × Claude Conversational AI × ElevenLabs TTS
Production implementation notes for an immersive language learning app integrating Whisper, Claude, and ElevenLabs in Rork Max. Covers CEFR-adaptive curriculum, SM-2 spaced repetition, streaming latency optimization, and a freemium pricing model that holds a 55 percent margin.
AI Models2026-05-06
Building an AI Writing Coach App with Rork Max — Complete Implementation Guide for Claude API Streaming, RevenueCat Subscriptions, and History Management
A complete guide to building an AI writing coach app using Rork Max and Claude API. Covers backend architecture, SSE streaming, RevenueCat subscription tiers, MMKV history persistence, and App Store strategy — production-ready code throughout.
AI Models2026-07-15
On-Device Image Classification: TFLite on React Native or Core ML on Rork Max — How I Chose After Building Both
Adding on-device image classification means choosing between TFLite on React Native and Core ML on Rork Max. I built the same feature both ways, measured the end-to-end breakdown, and worked out what the decision actually hinges on.
📚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 →