"Is this email actually correct?" is a question non-native English speakers ask dozens of times a week. Pasting text into ChatGPT works, but a purpose-built writing coach app delivers a dramatically better experience — faster to launch, more intuitive to use, with a searchable history of past corrections the user can reference anytime.
About three months ago, I shipped exactly this kind of app using Rork Max and the Claude API. It passed App Store review on the first submission, and it's been running in production since. This guide covers the full implementation — backend architecture, SSE streaming, subscription tiers, and local persistence — with code you can copy and adapt directly.
Why a Writing Coach App Earns Subscription Revenue
Repeating use is the foundation of subscription economics, and writing assistance has one of the strongest repeating-use patterns of any productivity category. Business emails, academic submissions, social messages to international colleagues — these are daily or weekly events for millions of people. Users don't download once and forget; they come back every time they write something important.
The competitive landscape is less daunting than it looks. ChatGPT and Grammarly are general-purpose tools. A purpose-built writing coach for a specific audience — non-native English speakers who need business email help, or Japanese professionals writing English reports — can win on focus and user experience even against better-funded competitors. You know your user's exact scenario; general tools do not.
From an App Store perspective, the keyword space is highly searchable. "Business email correction," "English writing coach," "grammar checker AI," and "writing feedback app" are all high-intent queries where users are actively seeking exactly what you're building. That search-driven discovery compounds over time.
The two-tier model I used works well in practice:
- Free: 5 corrections per month — enough for users to see real value, not enough for heavy users to stay free
- Premium (monthly): Unlimited corrections, 5,000-character limit, all four correction modes
Users who write English regularly hit the free limit within a week and upgrade naturally. The conversion rate has been higher than I expected.
App Architecture and Technology Choices
Before writing a line of code, I mapped out the minimal screen set that would make the app useful without creating scope creep.
The five screens I settled on:
- Home: Mode selection (Business Email, Academic, Casual, Japanese) and remaining free-use counter
- Input: Clean text area, character counter, and submit button
- Results: Streaming Markdown feedback with copy and save actions
- History: Chronological list of past corrections, MMKV-backed
- Settings: Premium plan information, RevenueCat paywall entry point, privacy information
The technology decisions, with reasoning:
Rork Max (SwiftUI native) over React Native Expo for this app. The primary reason was Markdown rendering quality — structured feedback with headings, bullet points, and code blocks looks noticeably better with MarkdownUI in SwiftUI than with the React Native Markdown equivalents I tested. App Store review turnaround is also faster for native apps in my experience.
Claude claude-sonnet-4-6 over claude-haiku-4-5 for the AI model. I benchmarked both on 50 real writing samples. Sonnet's feedback was consistently more specific and actionable. At $0.01–0.02 USD per correction, the cost is low enough to price a profitable subscription tier without difficulty.
Cloudflare Workers as the backend layer, because API keys cannot live in the mobile client. Workers also gives server-side usage enforcement that client-side code cannot bypass.
MMKV for local correction history storage. It reads and writes 5–10x faster than AsyncStorage, and 100 correction entries with full feedback text typically fits in under 5MB.
RevenueCat SDK for subscription management. The analytics dashboard alone justifies the integration — monthly churn, trial conversion, and LTV metrics available from day one.
Crafting System Prompts That Produce Useful Feedback
The quality gap between good and mediocre AI writing feedback comes almost entirely from the system prompt. Prompts that ask Claude to "improve the writing" produce generic responses. Prompts with explicit structure requirements and specific instruction on what "good" means in each mode produce consistent, actionable output.
Here are the full system prompts I settled on after iterating through about 15 versions:
const SYSTEM_PROMPTS = {
business_email: `You are a professional business writing coach with expertise in cross-cultural
business communication. Your role is to help non-native English speakers write emails that are
clear, professional, and appropriate for their audience.
When you receive a text to correct, respond ONLY in the following Markdown structure:
## Overall Assessment
Evaluate tone, clarity, and professionalism in 2–3 sentences. Be direct about the biggest issue.
## Key Issues
List 3–5 specific problems. Each bullet must name the exact phrase that is problematic,
explain why it is problematic, and give one alternative phrasing.
## Corrected Version
Provide the complete corrected text inside a code block. Do not truncate it.
## Why These Changes Matter
Explain the 2–3 most important changes and their likely impact on how the email will be received.
Important guidelines:
- Never say "improve clarity" or "be more professional" without showing exactly how
- If the email is already excellent, say so specifically and briefly — do not invent problems
- Preserve the sender's intended tone and meaning; your job is to polish, not rewrite from scratch`,
academic: `You are an academic writing coach for non-native English speakers working in
scientific and academic contexts. You have expertise in journal article structure, thesis writing,
and grant proposals.
Use the same four-section Markdown structure (Overall Assessment, Key Issues, Corrected Version,
Why These Changes Matter).
Focus specifically on: logical flow between sentences, hedging language (researchers use "suggests"
not "proves"), passive vs. active voice choices, paragraph topic sentences, and vocabulary register
(formal academic English, not business English).
Point out if the text reads as translated from another language and show specifically how to make
it read as originally written in English.`,
casual: `You are a friendly and encouraging writing coach helping with casual English —
text messages, social media posts, friend emails, and informal workplace chat.
Use the same four-section Markdown structure.
Prioritize naturalness over grammar rules. If a grammar "error" is acceptable in casual speech,
say so. Focus on: whether it sounds natural to a native speaker, idiom and collocation choices,
appropriate informality level for the context, and whether the message will land the way the
writer intends.
Keep a warm and supportive tone throughout.`,
japanese: `あなたはプロの日本語文章コーチです。ビジネス文書、論文、メール、SNS投稿など幅広い文章に対応します。
以下の Markdown 構成で必ず回答してください:
## 全体評価
文体・明確さ・読みやすさを2〜3文で評価してください。最も重要な問題点を率直に伝えてください。
## 改善すべき点
具体的な問題点を3〜5個箇条書きで。問題のある箇所を引用し、なぜ問題なのかを説明し、改善案を示してください。
## 添削後の文章
修正した文章の全文をコードブロックに記載してください。省略しないでください。
## なぜこの変更が重要か
最も重要な変更を2〜3点、その効果とともに説明してください。
文体(敬体・常体)は原文に合わせてください。書き直すのではなく、磨くことを心がけてください。`
};The key discipline in each prompt is: no vague advice. Every prompt explicitly forbids unhelpful output like "improve clarity" without a specific suggestion. This took several iterations to get right — early versions of Claude's feedback were accurate but generic, which caused users to rate the app lower even when the corrections themselves were correct.
For advanced prompt optimization techniques, see the AI Prompt Engineering Mastery Guide 2026.
Cloudflare Workers Backend with Streaming
The Workers backend serves two purposes: it keeps the Anthropic API key off the client, and it enforces usage limits that can't be bypassed by modifying the app. I used Hono for routing (see the Hono + Cloudflare Workers setup guide):
// worker/src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import Anthropic from '@anthropic-ai/sdk';
type Bindings = { ANTHROPIC_API_KEY: string; APP_SECRET: string };
type CorrectionMode = 'business_email' | 'academic' | 'casual' | 'japanese';
const CHAR_LIMITS = { free: 500, premium: 5000 } as const;
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', cors({ origin: '*' }));
app.use('/correct', async (c, next) => {
if (c.req.header('X-App-Secret') !== c.env.APP_SECRET) {
return c.json({ error: 'UNAUTHORIZED' }, 401);
}
await next();
});
app.post('/correct', async (c) => {
const { text, mode, userTier } = await c.req.json<{
text: string;
mode: CorrectionMode;
userTier: 'free' | 'premium';
}>();
if (!text || text.trim().length < 10) {
return c.json({ error: 'TEXT_TOO_SHORT', message: 'Provide at least 10 characters.' }, 400);
}
const limit = CHAR_LIMITS[userTier];
if (text.length > limit) {
return c.json({
error: 'CHAR_LIMIT_EXCEEDED', limit,
message: userTier === 'free'
? `Free tier: max ${limit} chars. Upgrade for ${CHAR_LIMITS.premium}.`
: `Exceeds ${limit} character limit.`
}, 403);
}
const client = new Anthropic({ apiKey: c.env.ANTHROPIC_API_KEY });
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const enc = new TextEncoder();
(async () => {
try {
const stream = client.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 2048,
system: SYSTEM_PROMPTS[mode],
messages: [{ role: 'user', content: text }],
});
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
await writer.write(enc.encode(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`));
}
}
await writer.write(enc.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
} catch (e) {
await writer.write(enc.encode(`data: ${JSON.stringify({ error: String(e) })}\n\n`));
} finally {
await writer.close();
}
})();
return new Response(readable, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
});
});
export default app;The character limit is enforced here, server-side. A client-side check is fine for UX (instant feedback without a network round-trip), but the server is the authoritative limit. For deeper SSE implementation context, the LLM Streaming SSE Complete Guide is a useful companion reference.
React Native Streaming Client
The hook handles SSE reception, error state management, usage counting, and history saving:
// hooks/useWritingCoach.ts
import { useState, useCallback, useRef } from 'react';
export type CorrectionMode = 'business_email' | 'academic' | 'casual' | 'japanese';
const WORKER_URL = 'https://your-worker.workers.dev';
const APP_SECRET = process.env.EXPO_PUBLIC_APP_SECRET ?? '';
const FREE_MONTHLY_LIMIT = 5;
export function useWritingCoach(tier: 'free' | 'premium') {
const [streamedFeedback, setStreamedFeedback] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [errorCode, setErrorCode] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const getRemainingFreeUses = useCallback((): number => {
if (tier === 'premium') return Infinity;
const key = `usage_${new Date().toISOString().slice(0, 7)}`;
const count = parseInt(localStorage.getItem(key) ?? '0', 10);
return Math.max(0, FREE_MONTHLY_LIMIT - count);
}, [tier]);
const submitForCorrection = useCallback(async (text: string, mode: CorrectionMode) => {
if (tier === 'free' && getRemainingFreeUses() <= 0) {
setErrorCode('MONTHLY_LIMIT_REACHED');
return;
}
// Cancel any in-flight stream before starting a new one
abortRef.current?.abort();
abortRef.current = new AbortController();
setStreamedFeedback(''); setIsLoading(true); setErrorCode(null);
try {
const res = await fetch(`${WORKER_URL}/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-App-Secret': APP_SECRET },
body: JSON.stringify({ text, mode, userTier: tier }),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
setErrorCode(err.error ?? 'API_ERROR');
return;
}
const reader = res.body?.getReader();
if (!reader) { setErrorCode('NO_STREAM'); return; }
const decoder = new TextDecoder();
let accumulated = '';
outer: while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.error) { setErrorCode('STREAM_ERROR'); break outer; }
if (parsed.done) {
if (tier === 'free') {
const key = `usage_${new Date().toISOString().slice(0, 7)}`;
const prev = parseInt(localStorage.getItem(key) ?? '0', 10);
localStorage.setItem(key, String(prev + 1));
}
// Save to MMKV history (implementation in next section)
saveToHistory(text, accumulated, mode);
break outer;
}
if (parsed.text) {
accumulated += parsed.text;
setStreamedFeedback(accumulated);
}
} catch { /* incomplete chunk — safe to skip */ }
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') setErrorCode('NETWORK_ERROR');
} finally {
setIsLoading(false);
}
}, [tier, getRemainingFreeUses]);
return {
streamedFeedback, isLoading, errorCode,
remainingFreeUses: getRemainingFreeUses(),
submitForCorrection,
cancelStream: () => { abortRef.current?.abort(); setIsLoading(false); },
};
}The AbortController pattern is essential. Without it, navigating away from the results screen while a stream is in progress causes the old stream's tokens to appear in the next request's output — a subtle bug that's hard to reproduce in development but reliably appears under real user conditions. For more on Claude API integration patterns, see Building an AI Assistant App with Claude API.
Feedback UI: Streaming Markdown Display
The results screen has two states — streaming and complete — and the transition between them matters for perceived quality:
// Views/FeedbackView.swift
import SwiftUI
import MarkdownUI
struct FeedbackView: View {
let feedback: String
let isStreaming: Bool
let onSave: () -> Void
@State private var copyConfirmed = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if isStreaming {
HStack(spacing: 8) {
ProgressView().scaleEffect(0.75)
Text("Analyzing…")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.horizontal)
}
// Pass a space when empty to prevent layout collapse
Markdown(feedback.isEmpty ? " " : feedback)
.markdownTheme(.feedbackTheme)
.padding(.horizontal)
.animation(.easeIn(duration: 0.08), value: feedback)
if !feedback.isEmpty && !isStreaming {
HStack(spacing: 12) {
Button {
UIPasteboard.general.string = feedback
copyConfirmed = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copyConfirmed = false }
} label: {
Label(copyConfirmed ? "Copied" : "Copy Feedback",
systemImage: copyConfirmed ? "checkmark" : "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button("Save", action: onSave).buttonStyle(.borderedProminent)
}
.padding([.horizontal, .bottom])
}
}
.padding(.top, 8)
}
.navigationTitle("Feedback")
.navigationBarTitleDisplayMode(.inline)
}
}
extension MarkdownTheme {
static var feedbackTheme: MarkdownTheme {
.basic
.text { FontSize(16); ForegroundColor(.primary) }
.code { FontFamilyVariant(.monospaced); FontSize(13); BackgroundColor(Color(.systemGray6)) }
.heading2 { FontWeight(.semibold); FontSize(18) }
}
}Passing " " (a single space) when feedback is empty prevents a MarkdownUI rendering glitch where the view collapses to zero height and produces a visible jump when content arrives. It's a minor detail that matters for polish.
RevenueCat Subscription with Offline Resilience
The most important design decision in the subscription service is the failure mode. When customerInfo() fails due to network issues, do not default to isPremium = false. That silently downgrades real premium users. Cache the last confirmed status and trust it under failure conditions:
// Services/SubscriptionService.swift
import RevenueCat
@MainActor
class SubscriptionService: ObservableObject {
@Published var isPremium = false
@Published var freeUsesRemaining = 5
private let cacheKey = "last_known_premium_status"
private let freeTierLimit = 5
init() {
// Initialize from cache immediately to avoid UI flicker on launch
isPremium = UserDefaults.standard.bool(forKey: cacheKey)
Task { await syncFromNetwork() }
}
func syncFromNetwork() async {
do {
let info = try await Purchases.shared.customerInfo()
let active = info.entitlements["premium"]?.isActive == true
isPremium = active
UserDefaults.standard.set(active, forKey: cacheKey)
if !active { freeUsesRemaining = calculateRemaining() }
} catch {
// Network unavailable: retain cached status
// Do NOT set isPremium = false here
}
}
func recordCorrectionUsage() {
guard !isPremium else { return }
let key = monthlyUsageKey()
UserDefaults.standard.set(UserDefaults.standard.integer(forKey: key) + 1, forKey: key)
freeUsesRemaining = calculateRemaining()
}
private func calculateRemaining() -> Int {
max(0, freeTierLimit - UserDefaults.standard.integer(forKey: monthlyUsageKey()))
}
private func monthlyUsageKey() -> String {
let d = Calendar.current.dateComponents([.year, .month], from: Date())
return "usage_\(d.year ?? 0)_\(d.month ?? 0)"
}
}For a complete guide to RevenueCat configuration, sandbox testing, and paywall design, see the RevenueCat Subscription Monetization Guide.
Correction History with Local Persistence
History is the feature that converts one-time users into retained users. People refer back to past corrections when writing similar content again. Implementation with MMKV (or UserDefaults for smaller data sets) is straightforward — the key is making load time imperceptible:
// Services/HistoryService.swift
struct CorrectionEntry: Codable, Identifiable {
var id: UUID = UUID()
let originalText: String
let feedback: String
let mode: String
let createdAt: Date
var isFavorited: Bool = false
var preview: String {
let t = originalText.trimmingCharacters(in: .whitespacesAndNewlines)
return t.count > 100 ? String(t.prefix(100)) + "…" : t
}
}
class HistoryService {
private let storageKey = "corrections_v1"
private let maxCount = 100
func save(text: String, feedback: String, mode: String) {
let entry = CorrectionEntry(
originalText: text, feedback: feedback,
mode: mode, createdAt: Date()
)
var all = loadAll()
all.insert(entry, at: 0)
store(Array(all.prefix(maxCount)))
}
func loadAll() -> [CorrectionEntry] {
guard let data = UserDefaults.standard.data(forKey: storageKey),
let entries = try? JSONDecoder().decode([CorrectionEntry].self, from: data)
else { return [] }
return entries
}
func toggleFavorite(id: UUID) {
var all = loadAll()
if let i = all.firstIndex(where: { $0.id == id }) {
all[i].isFavorited.toggle(); store(all)
}
}
func delete(id: UUID) {
store(loadAll().filter { $0.id != id })
}
// Export as plain text — useful for App Store review and user trust
func exportPlainText() -> String {
loadAll().map { e in
"[\(e.mode.uppercased())] \(e.createdAt.formatted(date: .numeric, time: .shortened))\n" +
"Original:\n\(e.originalText)\n\nFeedback:\n\(e.feedback)"
}.joined(separator: "\n\n---\n\n")
}
private func store(_ entries: [CorrectionEntry]) {
if let data = try? JSONEncoder().encode(entries) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
}The export feature earns disproportionate goodwill in App Store reviews. "I can export my corrections" shows up repeatedly in positive reviews, and App Store reviewers notice it as a data-transparency signal. Worth the 15 lines it takes to implement. For the MMKV vs AsyncStorage vs SQLite trade-offs for larger data sets, see Local Storage Options in Rork Apps.
Three Production Pitfalls to Anticipate
These issues don't appear in development but are reliable under production conditions.
1. iOS suspends SSE streams during backgrounding
When a user locks their screen mid-correction, iOS suspends network activity. The stream terminates silently, leaving feedback half-rendered. The cleanest fix is monitoring ScenePhase and explicitly cancelling the active stream when the app enters .background, then showing a "Correction interrupted — tap to retry" state when it returns to .active. Displaying truncated feedback that looks complete is worse than showing an explicit retry prompt.
2. Claude's feedback is thorough — sometimes too thorough
Sonnet is detailed. For a 100-word email, you might get 700 words of structured feedback. Some users find this overwhelming. Adding a detail_level parameter to the system prompt ("Brief: key corrections only" vs "Detailed: full analysis") resolved this. The brief mode also reduces token cost by roughly 40%, which compounds significantly at scale.
3. Month rollover depends on device time
The local usage counter uses Date() to identify the current month. Users who change their device clock can reset their free usage. For early-stage apps this is an acceptable trade-off — implement server-side counting (keyed to RevenueCat appUserId, stored in Cloudflare KV) only after you've validated that the abuse is real enough to justify the added complexity.
App Store Submission: Privacy and Review Notes
Writing coach apps touch user-submitted text, which means App Store reviewers scrutinize the privacy section carefully. Two things that helped the review go smoothly:
First, a one-time consent screen on first launch that clearly explains text is sent to Anthropic's API and links to their privacy policy. The wording I used: "To provide writing feedback, your text is sent to Anthropic's AI model. Anthropic's Privacy Policy applies to this processing."
Second, the Privacy Nutrition Label should accurately declare: "Data Used to Track You" — none; "Data Linked to You" — none; "Data Not Linked to You" — user content (text submitted for correction). Being accurate rather than minimal here reduces the chance of a reviewer flag.
The App Store privacy review asked specifically about third-party data sharing, which the Anthropic API connection triggers. Having the consent screen in place and the privacy policy accurately updated made the question easy to answer.
Start with Business Email Mode
You don't need all four correction modes to ship. Pick "Business Email" — it's the highest-intent keyword in the App Store — implement it, and get it into TestFlight. Real users will tell you what mode they want next far more reliably than internal planning will.
The Rork Max and Claude API combination is genuinely well-suited to this category of app. The streaming feedback feels immediate, the Markdown output is readable, and RevenueCat gets you monetization analytics on day one. If you've been thinking about building a writing coach app, this stack removes most of the friction.
ASO Strategy: Keywords That Convert for Writing Apps
Getting organic downloads from App Store search is realistic in this niche. Writing assistance is a high-intent category — users searching for these terms want to purchase or at least try a specific type of app. The keyword strategy that has worked well:
Primary title keywords: Place your single most important keyword in the app name itself. "AI Writing Coach" or "English Email Corrector" in the app title carries the most ASO weight. Pick one term based on what App Store Connect's keyword planner shows as highest-volume with manageable competition.
The 100-character keyword field: Don't repeat words that already appear in your title. Fill the keyword field with complementary terms: "grammar check,business email,English writing,text correction,proofreading,writing assistant,email checker,English coach". Commas separate terms; spaces within a term are interpreted as an AND relationship by the App Store algorithm.
Subtitle (30 characters): Use this for a secondary keyword cluster that supports conversion. "Smart Grammar & Style Coach" communicates value and targets different search patterns than the title.
Screenshots: Show the feedback in action. The most effective screenshot in my testing was a before/after showing an awkward business email transformed into polished professional writing. Users scanning search results respond to concrete evidence of what the app does.
Ratings and reviews: The first 20 ratings matter disproportionately. Prompt the in-app review dialog after the user's second or third correction — they've seen enough value to rate positively but haven't yet formed the habit of dismissing the prompt. Use SKStoreReviewRequest.requestReview() conditionally, only after a success state.
Pricing Psychology: Designing the Free Tier
The decision about what to include in the free tier is more important than the premium price itself. Too restrictive and users don't reach the "aha" moment. Too generous and they never convert.
Five corrections per month works because:
- It takes about two corrections to see that the feedback quality is genuinely useful (not just a synonym suggester)
- The third or fourth correction creates the habit — now the user reaches for the app automatically
- Heavy users (daily writers) hit five within a week and convert; casual users are still getting real value on free
The alternative I considered — a 7-day free trial with full access — converts at roughly the same rate but resets the user's relationship with the app at day 8 when features disappear. The rolling monthly limit feels more natural and less adversarial.
For the premium price, I landed on $6.99 USD/month. This is above Grammarly's basic tier but well below their premium, which positions the app as a specialist that justifies its price through focus. The local currency equivalents (¥980 in Japan, €6.49 in Europe) were set to match the platform's expected price points rather than direct currency conversion.
Monitoring and Analytics After Launch
Once the app is live, a few metrics tell you almost everything about the business:
Correction completion rate: What percentage of users who tap "Submit" receive a full response? Low completion rates indicate streaming errors or timeout issues. Measure this by logging done: true events from the Worker.
Mode distribution: Which correction mode do users select most? If 70% choose Business Email, that tells you where to focus next-feature investment. Log mode selection as an analytics event.
Free-to-premium conversion rate by cohort: Users who complete their 5th free correction in week 1 convert at a different rate than those who complete them spread over a month. RevenueCat's cohort analytics surface this automatically, but it's worth querying explicitly when making pricing decisions.
Cancellation timing: When do premium users cancel? If it's within 48 hours of subscribing, the paywall is overpromising. If it's after exactly 30 days, users are one-month experimenters who didn't find a repeating use case. Different problems, different solutions.
For production monitoring of errors and crashes, the approach in App Quality Metrics with Crashlytics and Play Vitals applies directly to this app's monitoring setup.
The Architecture Scales
One of the underappreciated aspects of this setup is how well it scales from solo project to real product. The Cloudflare Workers backend can handle thousands of concurrent SSE streams at the free tier. RevenueCat's SDK absorbs subscription complexity that would otherwise require months of backend engineering. MMKV is fast enough that you won't need to migrate to a database backend until you're storing thousands of entries per user. And Claude's API quality means the core product — the feedback itself — doesn't degrade with scale.
The writing coach niche rewards focus. Build one mode well, ship it, and let real user behavior tell you where to go next.