◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/AI Models
◉ AI Models/2026-05-05Advanced

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

How I built a production-ready AI interview coach app with Rork Max — expo-audio recording, Whisper transcription, Claude evaluation, a JSON extractor measured against six response shapes, and a free tier that is counted on the server.

AI Interview CoachRork Max236Claude API11Voice RecognitionWhisper4CareerSubscription23React Native239

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.

What follows is the pipeline I ended up with, along with the two places it broke on me: the way I pulled JSON out of the model's reply, and the place where I counted the free tier. Voice recording, transcription via Whisper, evaluation via Claude, session storage, and a freemium model — with working code, and with the measurements that made me rewrite two of those pieces.

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's Messages API 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. The model ID below is claude-sonnet-4-6; move up a tier for finer evaluation or down to a Haiku model for cost. Whichever you pick, keep the response handling described two sections down.

Results and session history are stored in Supabase.

[Rork Max App]
     ↓ audio file (m4a/mp4)
[Cloudflare Workers]
     ↓ forward
[Whisper API (OpenAI)]
     ↓ transcript
[Claude Messages 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-audio

One library note before the code. Ask any model for Expo recording code and you'll get expo-av and Audio.Recording, because that's what most of the training data contains. The Audio API in expo-av was deprecated in SDK 53 in favour of expo-audio, and the SDK 54 docs say expo-av itself is slated for removal in SDK 55.

For a while I let the generated version stand, since it ran. The bill comes later: you bump the SDK one morning and only the recording screen fails to build. What generation gives you is the majority spelling, not the current one. Audio, video and filesystem APIs churn faster than most, so I now spend one pass checking generated code against the primary docs before it goes anywhere near a build.

With that settled, the prompt:

"Create a 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 useAudioRecorder from expo-audio, not expo-av."

Then replace the recording logic with this hook:

// hooks/useInterviewRecorder.ts
import { useEffect, useState } from 'react';
import {
  useAudioRecorder,
  useAudioRecorderState,
  AudioModule,
  RecordingPresets,
  setAudioModeAsync,
} from 'expo-audio';
 
const MAX_DURATION_SEC = 180; // cap answers at 3 minutes
 
export function useInterviewRecorder() {
  // Recordings land in the cache directory by default, which the OS may clear
  // when storage runs low. Keep them in documents until evaluation finishes.
  const recorder = useAudioRecorder({
    ...RecordingPresets.HIGH_QUALITY,
    directory: 'document',
  });
  const state = useAudioRecorderState(recorder, 250);
  const [permissionGranted, setPermissionGranted] = useState(false);
 
  useEffect(() => {
    (async () => {
      const status = await AudioModule.requestRecordingPermissionsAsync();
      setPermissionGranted(status.granted);
      // iOS defaults allowsRecording to false — without this, nothing records
      await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
    })();
  }, []);
 
  const start = async () => {
    if (!permissionGranted) {
      throw new Error('Microphone permission is required');
    }
    await recorder.prepareToRecordAsync();
    // record() is synchronous — awaiting it waits for nothing
    recorder.record({ forDuration: MAX_DURATION_SEC });
  };
 
  const stop = async (): Promise<string> => {
    await recorder.stop();
    // Leaving the recording session open interferes with other apps' playback
    await setAudioModeAsync({ allowsRecording: false });
 
    const uri = recorder.uri; // the state object exposes `url`, the recorder `uri`
    if (!uri) {
      throw new Error('Recording file not found');
    }
    return uri;
  };
 
  return {
    isRecording: state.isRecording,
    durationSec: Math.floor(state.durationMillis / 1000),
    permissionGranted,
    start,
    stop,
  };
}

Let the library enforce the cap through record({ forDuration }) rather than watching a timer yourself — a timer you own will miss the stop when the app goes to the background. Answers past three minutes aren't good interview practice anyway, and transcription cost scales with duration.

Three small things that cost me time: record() is synchronous, so the promises worth awaiting are prepareToRecordAsync() and stop(). The file path lives on recorder.uri, while the state object from useAudioRecorderState calls the same thing url. And iOS keeps allowsRecording off unless setAudioModeAsync says otherwise — I spent an evening sending silent files to Whisper before I found that one.

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 Messages API
      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 }>;
        stop_reason: string;
      };
 
      // A truncated reply can only parse into broken JSON. The API tells you
      // directly, so branch on stop_reason before touching the text.
      if (claudeData.stop_reason === 'max_tokens') {
        return new Response(
          JSON.stringify({ error: 'The evaluation was cut short. Please try again.', retryable: true }),
          { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
        );
      }
 
      const rawText = claudeData.content[0]?.text ?? '';
      const evaluation = extractEvaluation(rawText); // implemented in the next section
      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.

Pulling the Evaluation JSON Out of Six Response Shapes

This is where the 500 I mentioned at the top came from. The original code took everything between the first { and the last } with rawText.match(/\{[\s\S]*\}/) and handed it to JSON.parse. Most replies survive that. The ones that don't leave the user staring at an empty screen.

So I laid the failure modes out side by side. One evaluation payload, six response shapes I've actually observed, run through both extractors (Node.js v22, sandbox):

Response shapeGreedy regexBrace-counting extractor
A: bare JSONOKOK
B: "Here is the evaluation:" preambleOKOK
C: wrapped in a json code fenceOKOK
D: prose containing braces after the JSONthrows a 500OK
E: truncated at max_tokensthrows a 500reported as truncated
F: braces inside the preamblethrows a 500OK

Three of six. What the failures share is the greedy reach to the last closing brace: D and F swallow braces from the surrounding prose, and E hands JSON.parse a string that was never closed.

The replacement walks the text, tracks whether it's inside a string literal, counts brace depth, and takes the first balanced object:

// src/extract-evaluation.ts
export class IncompleteEvaluationError extends Error {}
 
function isEvaluation(value: unknown): value is EvaluationResult {
  const v = value as EvaluationResult;
  return !!v && typeof v === 'object'
    && !!v.scores && typeof v.overall === 'number'
    && Array.isArray(v.improvements) && typeof v.tips === 'string';
}
 
export function extractEvaluation(text: string): EvaluationResult {
  // Strip the outer fence only; inner braces get counted, not matched
  const fence = new RegExp('\\u0060{3}(?:json)?\\s*([\\s\\S]*?)\\u0060{3}', 'g');
  const unfenced = text.replace(fence, '$1');
 
  for (let i = unfenced.indexOf('{'); i !== -1; i = unfenced.indexOf('{', i + 1)) {
    let depth = 0;
    let inString = false;
    let escaped = false;
 
    for (let j = i; j < unfenced.length; j++) {
      const c = unfenced[j];
      if (escaped) { escaped = false; continue; }
      if (c === '\\') { escaped = true; continue; }
      if (c === '"') { inString = !inString; continue; }
      if (inString) continue;
 
      if (c === '{') {
        depth++;
      } else if (c === '}' && --depth === 0) {
        try {
          const value = JSON.parse(unfenced.slice(i, j + 1));
          if (isEvaluation(value)) return value; // shape has to match too
        } catch {
          // unusable chunk — resume the search at the next {
        }
        break;
      }
    }
  }
  throw new IncompleteEvaluationError('No complete evaluation object in the response');
}

My first version had no isEvaluation check, and on the truncated case it happily returned the inner scores object as "the first balanced chunk" — an evaluation with no score in it, rendered straight to the user. A 500 would have been kinder. A wrong-shaped value that passes is harder to find than a crash. Validating the shape in the same step that extracts it is what closed that class of report.

On the Worker side, catch IncompleteEvaluationError, return 503 with retryable: true, and let the app retry the evaluation once. The transcript already exists, so the retry costs one Claude call, not another upload.

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>
  );
}

Counting the Free Tier on the Device

There's a flaw in the code above that I shipped and lived with for a while: todayCount is device state.

Counted on the device, a limit is a request rather than a limit. Reinstall the app and the counter resets. More to the point, the Worker endpoint is open to anyone — CORS set to *, no authentication — so a single curl invocation walks straight past both the free tier and the paywall. The invoices still arrive from OpenAI and Anthropic, addressed to you.

If a count decides who pays, the count belongs on the server. Keep the client-side counter as well: "2 sessions left" has to render instantly, and only the device can do that. Display on the client, decide on the server — both stay, with different jobs.

// Cloudflare Workers: src/authorize.ts
import { createClient } from '@supabase/supabase-js';
 
const FREE_DAILY_LIMIT = 3;
 
// Start of "today" in the user's business timezone, not UTC
function startOfTodayJst(): string {
  const jst = new Date(Date.now() + 9 * 60 * 60 * 1000);
  const midnightUtc = Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth(), jst.getUTCDate());
  return new Date(midnightUtc - 9 * 60 * 60 * 1000).toISOString();
}
 
type AuthResult =
  | { ok: true; userId: string }
  | { ok: false; status: number; error: string };
 
export async function authorize(request: Request, env: Env): Promise<AuthResult> {
  const jwt = request.headers.get('Authorization')?.replace(/^Bearer\s+/i, '');
  if (!jwt) {
    return { ok: false, status: 401, error: 'Sign-in required' };
  }
 
  const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY);
  const { data, error } = await supabase.auth.getUser(jwt);
  if (error || !data.user) {
    return { ok: false, status: 401, error: 'Invalid session' };
  }
  const userId = data.user.id;
 
  // Entitlement comes from the RevenueCat webhook writing into `profiles`.
  // Never let the client assert "I'm premium".
  const { data: profile } = await supabase
    .from('profiles')
    .select('is_premium')
    .eq('id', userId)
    .single();
 
  if (profile?.is_premium) {
    return { ok: true, userId };
  }
 
  const { count } = await supabase
    .from('practice_sessions')
    .select('id', { count: 'exact', head: true })
    .eq('user_id', userId)
    .gte('created_at', startOfTodayJst());
 
  if ((count ?? 0) >= FREE_DAILY_LIMIT) {
    return { ok: false, status: 402, error: "You've used today's free practice sessions" };
  }
  return { ok: true, userId };
}

Call it at the top of fetch, before formData() — rejecting after you've read the audio means paying for the transfer you just refused.

const auth = await authorize(request, env);
if (!auth.ok) {
  return new Response(JSON.stringify({ error: auth.error }), {
    status: auth.status,
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });
}

One note on the day boundary. Cutting created_at on UTC days means users in Tokyo get their free sessions back at 9am — and evening is exactly when people practise. Cut the day in the timezone your users live in.

Three more guards worth the ten minutes: pin Access-Control-Allow-Origin to your app's origin instead of *, cap the upload size (5 MB covers three minutes of AAC), and check Content-Length so oversized bodies get a 413 before you read them.

Common Pitfalls

Pitfall 1: Leaving the iOS recording session open After stop(), call setAudioModeAsync({ allowsRecording: false }). Skip it and your app interferes with other apps' playback on iOS. The hook above does this; generated code usually doesn't. (The expo-av spelling was allowsRecordingIOS — another name to fix while migrating.)

Pitfall 2: The preset changes the format on Android only In expo-audio, RecordingPresets.HIGH_QUALITY produces .m4a (AAC) on both platforms, while LOW_QUALITY drops Android to .3gp AMR-NB. Choosing the smaller preset to save bandwidth gives you transcription failures on some Android devices and nowhere else — a miserable bug to narrow down. Interview answers are short; stay on HIGH_QUALITY.

Pitfall 3: Trusting one regex to extract the JSON As measured above, the greedy regex fails on three of six realistic response shapes. Count braces, validate the shape, and catch truncation with stop_reason before you parse anything.

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: Where the recordings live, and who deletes them expo-audio writes to the cache directory by default, which the OS may clear under storage pressure — hence directory: 'document' for anything you need until evaluation returns. That choice makes cleanup your job: call FileSystem.deleteAsync(uri) once the evaluation lands. If playback is a premium feature, upload to Supabase Storage first and keep the URL on the session row.

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: deploy the Worker and push one thirty-second answer of your own through transcription and evaluation. Two numbers decide whether this is worth building — how accurate the evaluation feels, and how long you sit there waiting for it. Everything else, the UI polish and the question bank, layers on afterwards.

One thing I'd do differently if I started again: put authorize() in front of the pipeline on day one, before there's a single user. Adding a gate to an endpoint that's already in the wild is a much less pleasant afternoon. Thanks for reading this far.

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-17
We Stopped Sending Push Notifications and Churn Dropped — Building an AI-Powered Churn Prediction System in Rork Max
Learn how to build a churn prediction system in Rork Max using Supabase Edge Functions, Claude API for personalized messages, and OneSignal for targeted delivery. Full implementation with code.
◉ AI Models2026-03-27
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.
◉ AI Models2026-09-12
Ask Rork for an iOS 27 feature and you may get last year's code back, with no error at all
iOS 27 ships on September 14. When a model is asked for an API it has never seen, there are two kinds of failure: the kind that stops your build, and the kind that does not. Here is why standard Rork and Rork Max differ, and the order I check generated code in.