RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/AIモデル
AIモデル/2026-05-06上級

Rork Max で作る AI ライティングコーチアプリ実装ガイド — Claude API ストリーミング・RevenueCat サブスク・履歴管理まで

Rork Max と Claude API を組み合わせた AI 文章添削コーチアプリの完全実装ガイド。バックエンド設計・SSE ストリーミング・RevenueCat サブスク・MMKV 履歴管理まで、リリースできるレベルのコードで解説します。

Rork Max229Claude API11AIライティングSSEストリーミングRevenueCat28Cloudflare Workers24MMKV6サブスクリプション63

「英語でメールを書くとき、本当に正しいのか確かめる手段がない」という話を、個人開発者のコミュニティでよく耳にします。ChatGPT に貼り付けるという選択肢はあるけれど、専用アプリのほうがずっと使いやすいはずだと感じていました。

3ヶ月ほど前、その課題をプロダクトとして形にしてみました。Rork Max と Claude API を使い、英語・日本語の両方に対応した文章添削コーチアプリを構築して App Store に提出した経験から、実装の全過程を公開します。技術的にやや高度な内容も含みますが、Rork Max を日常的に使っている方であれば理解できる範囲で書いています。コードはコピー&ペーストで動作することを目標にしました。

なぜ「AI ライティングコーチ」がサブスク収益化に向いているのか

ライティング支援アプリが収益化しやすい理由は、使用動機が繰り返し発生することにあります。ユーザーが「英語メールを書く」「論文を提出する」「ビジネス文書を作成する」という場面は週に何度も訪れます。一回使って終わりではなく、習慣的に使い続けてもらえる可能性が高い。

競合は ChatGPT などの汎用 AI ツールですが、専用アプリには固有の強みがあります。起動が速い、操作が直感的、履歴が整理されている、ビジネスメール・学術論文・カジュアルメッセージといったモードを切り替えられる、といった体験の差が積み重なります。App Store での ASO の観点でも、「英語文章校正」「ビジネスメール添削」「ライティングコーチ」といった具体的なキーワードで検索するユーザーを獲得しやすいカテゴリです。

価格設計としては、月5回まで無料、月額プレミアムで無制限、という2段階が機能しやすいと感じました。5回という制限は「試してみようか」と思えるラインであり、毎日使いたいユーザーには自然にアップグレードを促せます。

アプリの全体設計と技術スタック

画面構成はシンプルに5画面で構成しました。

  • ホーム画面: 添削モード選択(ビジネスメール・学術文・カジュアル・日本語)と残り無料回数の表示
  • 入力画面: テキストエリアと「添削する」ボタン
  • 結果画面: Claude のフィードバックをストリーミング表示(Markdown レンダリング付き)
  • 履歴画面: 過去の添削一覧(MMKV ローカル保存)
  • 設定画面: プレミアムプラン案内・RevenueCat ペイウォール

技術スタックの選定理由も含めて整理します。

  • Rork Max: SwiftUI ネイティブアプリを選択。React Native Expo も検討しましたが、Markdown レンダリングの品質と App Store の審査スピードを優先してネイティブにしました
  • Claude API (claude-sonnet-4-6): claude-haiku と迷いましたが、添削の質で差が出るのでコストを払っても Sonnet を使う判断をしました。API コール1回あたりの費用は約0.5〜2円程度です
  • Cloudflare Workers: API キーをクライアントに持たせず、Worker 経由でルーティングします。SSE ストリーミングへの対応も Workers の Edge Runtime で問題なく動きます
  • MMKV: AsyncStorage より5〜10倍高速。添削履歴を50〜100件保存する程度なら MMKV で十分です
  • RevenueCat SDK: Storekit 2 の直接実装と迷いましたが、Android 対応の可能性と詳細な分析機能を考えると RevenueCat の導入コストは十分に回収できます

Cloudflare Workers バックエンドと Claude API ストリーミング実装

API キーをモバイルクライアントに直接持たせることは、バンドル解析やネットワークキャプチャで露出するリスクがあります。Cloudflare Workers を中間層として挟み、クライアントからは Worker への認証済みリクエストだけを送る設計にしました。

Worker 側のコードです。Hono フレームワークを使っています(Hono + Cloudflare Workers の詳細な設定方法も参考にしてください)。

// 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;
  ALLOWED_APP_SECRET: string;
};
 
type CorrectionMode = 'business_email' | 'academic' | 'casual' | 'japanese';
 
const SYSTEM_PROMPTS: Record<CorrectionMode, string> = {
  business_email: `You are a professional business writing coach specializing in English business emails. 
Analyze the provided text and respond with structured Markdown feedback using these sections:
## Overall Assessment
(2-3 sentences on tone, clarity, and professionalism)
## Key Issues
(Bullet list of specific problems with brief explanations)
## Corrected Version
(Provide the full corrected text in a code block)
## Why These Changes Matter
(Explain the most important 2-3 changes and their impact)
Be specific and actionable. Avoid vague advice like "improve clarity."`,
  academic: `You are an academic writing coach for non-native English speakers.
Focus on: logical flow, argument structure, formal vocabulary, citation style issues, and grammar.
Use the same Markdown section format as instructed.`,
  casual: `You are a friendly writing coach helping with casual English communication.
Keep feedback encouraging. Focus on naturalness, idiom usage, and social tone.
Use the same Markdown section format.`,
  japanese: `あなたはプロの日本語文章コーチです。
提供されたテキストを分析し、以下の Markdown 形式でフィードバックしてください:
## 全体評価
(文体・明確さ・読みやすさを2〜3文で評価)
## 改善すべき点
(具体的な問題点を箇条書きで、理由付きで)
## 添削後の文章
(修正後の全文をコードブロックに)
## なぜこの変更が重要か
(最も重要な変更を2〜3点、その効果とともに説明)`,
};
 
const FREE_TIER_CHAR_LIMIT = 500;
const PREMIUM_TIER_CHAR_LIMIT = 5000;
 
const app = new Hono<{ Bindings: Bindings }>();
 
app.use('*', cors({ origin: '*' }));
 
// アプリシークレット検証ミドルウェア
app.use('/correct', async (c, next) => {
  const appSecret = c.req.header('X-App-Secret');
  if (appSecret !== c.env.ALLOWED_APP_SECRET) {
    return c.json({ error: 'UNAUTHORIZED' }, 401);
  }
  await next();
});
 
app.post('/correct', async (c) => {
  const body = await c.req.json<{
    text: string;
    mode: CorrectionMode;
    userTier: 'free' | 'premium';
  }>();
 
  const { text, mode, userTier } = body;
 
  // 入力バリデーション
  if (!text || text.trim().length < 10) {
    return c.json({ error: 'TEXT_TOO_SHORT', message: 'Please provide at least 10 characters.' }, 400);
  }
 
  const charLimit = userTier === 'premium' ? PREMIUM_TIER_CHAR_LIMIT : FREE_TIER_CHAR_LIMIT;
  if (text.length > charLimit) {
    return c.json({
      error: 'CHAR_LIMIT_EXCEEDED',
      limit: charLimit,
      message: userTier === 'free'
        ? `Free users can submit up to ${charLimit} characters. Upgrade to premium for up to ${PREMIUM_TIER_CHAR_LIMIT} characters.`
        : `Text exceeds the ${charLimit} character limit.`
    }, 403);
  }
 
  const client = new Anthropic({ apiKey: c.env.ANTHROPIC_API_KEY });
 
  // SSE ストリーミングレスポンス
  const stream = new TransformStream();
  const writer = stream.writable.getWriter();
  const encoder = new TextEncoder();
 
  // 非同期でストリーミング処理
  (async () => {
    try {
      const anthropicStream = 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 anthropicStream) {
        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
          await writer.write(
            encoder.encode(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`)
          );
        }
      }
 
      await writer.write(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
    } catch (err) {
      await writer.write(
        encoder.encode(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : 'Stream error' })}\n\n`)
      );
    } finally {
      await writer.close();
    }
  })();
 
  return new Response(stream.readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'X-Accel-Buffering': 'no',
    },
  });
});
 
export default app;

このコードの設計で特に重要な点が2つあります。まず ALLOWED_APP_SECRET によるアプリ認証です。完璧なセキュリティではありませんが、バンドルに埋め込んだシークレットとサーバー側の照合を組み合わせることで、不正利用の大部分を防げます。次に文字数制限をサーバー側で実施していることです。クライアント側の制限はバイパスされる可能性があるため、サーバーが最終的な検証を担当する設計にしています。

SSE ストリーミングの詳細な実装パターンについてはLLM ストリーミング・SSE リアルタイム AI レスポンス完全実装ガイドも参考にしてください。

React Native クライアント側のストリーミング受信実装

Worker からの SSE を React Native でリアルタイムに受信するカスタムフックです。エラーハンドリングを丁寧に実装しています。

// hooks/useWritingCoach.ts
import { useState, useCallback, useRef } from 'react';
 
export type CorrectionMode = 'business_email' | 'academic' | 'casual' | 'japanese';
export type UserTier = 'free' | 'premium';
 
interface UsageTracker {
  count: number;
  resetDate: string; // YYYY-MM-DD
}
 
const WORKER_URL = 'https://your-worker.your-subdomain.workers.dev';
const APP_SECRET = process.env.EXPO_PUBLIC_APP_SECRET ?? '';
const FREE_MONTHLY_LIMIT = 5;
 
export function useWritingCoach(tier: UserTier) {
  const [streamedFeedback, setStreamedFeedback] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [errorCode, setErrorCode] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);
 
  const getRemainingFreeUses = useCallback((): number => {
    if (tier === 'premium') return Infinity;
    // MMKV から使用回数を読む(後述)
    const usage = getUsageFromStorage();
    const today = new Date().toISOString().slice(0, 7); // YYYY-MM
    if (usage.resetDate !== today) return FREE_MONTHLY_LIMIT;
    return Math.max(0, FREE_MONTHLY_LIMIT - usage.count);
  }, [tier]);
 
  const submitForCorrection = useCallback(async (
    text: string,
    mode: CorrectionMode
  ): Promise<void> => {
    // 無料ユーザーの残回数チェック
    if (tier === 'free' && getRemainingFreeUses() <= 0) {
      setErrorCode('MONTHLY_LIMIT_REACHED');
      return;
    }
 
    // 前回のストリームをキャンセル
    abortControllerRef.current?.abort();
    abortControllerRef.current = new AbortController();
 
    setStreamedFeedback('');
    setIsLoading(true);
    setErrorCode(null);
 
    try {
      const response = 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: abortControllerRef.current.signal,
      });
 
      if (!response.ok) {
        const errorBody = await response.json();
        setErrorCode(errorBody.error ?? 'API_ERROR');
        return;
      }
 
      // SSE ストリームを読み込む
      const reader = response.body?.getReader();
      if (!reader) {
        setErrorCode('NO_STREAM');
        return;
      }
 
      const decoder = new TextDecoder();
      let accumulated = '';
 
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
 
        const chunk = decoder.decode(value, { stream: true });
        const lines = chunk.split('\n');
 
        for (const line of lines) {
          if (!line.startsWith('data: ')) continue;
          try {
            const parsed = JSON.parse(line.slice(6));
 
            if (parsed.error) {
              setErrorCode('STREAM_ERROR');
              return;
            }
            if (parsed.done) {
              // 完了処理
              if (tier === 'free') incrementUsageCount();
              saveCorrectionToHistory({ originalText: text, feedback: accumulated, mode });
              return;
            }
            if (parsed.text) {
              accumulated += parsed.text;
              setStreamedFeedback(accumulated);
            }
          } catch {
            // 不完全なチャンクは無視
          }
        }
      }
    } catch (err) {
      if ((err as Error).name === 'AbortError') return; // ユーザーキャンセル
      setErrorCode('NETWORK_ERROR');
    } finally {
      setIsLoading(false);
    }
  }, [tier, getRemainingFreeUses]);
 
  const cancelStream = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsLoading(false);
  }, []);
 
  return {
    streamedFeedback,
    isLoading,
    errorCode,
    remainingFreeUses: getRemainingFreeUses(),
    submitForCorrection,
    cancelStream,
  };
}

AbortController を使ったキャンセル処理は、入力画面に戻ったときや次の添削を開始するときに前のストリームを正しく終了させるために不可欠です。これを実装し忘れると、古いストリームからの応答が画面に混入するという厄介なバグが発生します。

Claude API とストリーミングについてより詳しく知りたい方はClaude API でAIアシスタントアプリを作るとプロンプトキャッシングと拡張思考の実践ガイドもあわせてご覧ください。

添削フィードバックの Markdown UI 実装

Claude からのフィードバックは Markdown 形式で届くため、そのままテキストとして表示するのではなく、適切にレンダリングする必要があります。Rork Max(SwiftUI)では MarkdownUI ライブラリが使いやすいです。

// Views/FeedbackView.swift
import SwiftUI
import MarkdownUI
 
struct FeedbackView: View {
  let feedback: String
  let isStreaming: Bool
  let onCopy: () -> Void
  let onSave: () -> Void
 
  @State private var showCopied = false
 
  var body: some View {
    ScrollView {
      VStack(alignment: .leading, spacing: 16) {
        if isStreaming {
          StreamingIndicator()
        }
 
        // Markdown レンダリング
        Markdown(feedback)
          .markdownTheme(.custom)
          .padding(.horizontal, 16)
          .animation(.easeIn(duration: 0.1), value: feedback)
 
        // アクションボタン
        HStack(spacing: 12) {
          Button(action: {
            UIPasteboard.general.string = feedback
            withAnimation { showCopied = true }
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
              withAnimation { showCopied = false }
            }
          }) {
            Label(showCopied ? "コピー済み" : "コピー", systemImage: showCopied ? "checkmark" : "doc.on.doc")
              .frame(maxWidth: .infinity)
          }
          .buttonStyle(.bordered)
 
          Button("保存", action: onSave)
            .buttonStyle(.borderedProminent)
        }
        .padding(.horizontal, 16)
        .padding(.bottom, 24)
      }
    }
    .navigationTitle("添削結果")
    .navigationBarTitleDisplayMode(.inline)
  }
}
 
// カスタムMarkdownテーマ
extension MarkdownTheme {
  static var custom: MarkdownTheme {
    .basic.text {
      FontSize(17)
      ForegroundColor(.primary)
    }
    .code {
      FontFamilyVariant(.monospaced)
      FontSize(14)
      BackgroundColor(Color(.systemGray6))
    }
    .heading2 {
      FontWeight(.semibold)
      FontSize(20)
    }
  }
}
 
// ストリーミング中のインジケーター
struct StreamingIndicator: View {
  @State private var dotCount = 1
 
  var body: some View {
    HStack(spacing: 4) {
      Image(systemName: "waveform")
        .foregroundColor(.accentColor)
      Text("AI が分析中" + String(repeating: ".", count: dotCount))
        .font(.caption)
        .foregroundColor(.secondary)
    }
    .padding(.horizontal, 16)
    .onAppear {
      Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
        dotCount = dotCount % 3 + 1
      }
    }
  }
}

ストリーミング中にテキストが増えていく様子をユーザーに見せることで、「処理が走っている」という安心感を与えられます。静止画ではなくアニメーションで状態を伝えるというのは、添削アプリのUXで特に効果が出やすいポイントです。

無料枠制限と RevenueCat サブスク実装

使用制限のカウントと RevenueCat によるサブスク管理を組み合わせた実装です。MMKV を使ったローカルカウンターと、RevenueCat のエンタイトルメントによる判定を連動させます(RevenueCat サブスク実装の詳細も参照してください)。

// Services/SubscriptionService.swift
import Foundation
import RevenueCat
 
@MainActor
class SubscriptionService: ObservableObject {
  @Published var isPremium = false
  @Published var remainingFreeUses: Int = 5
 
  private let usageKey = "monthly_usage"
  private let freeTierLimit = 5
 
  init() {
    Task { await refreshSubscriptionStatus() }
  }
 
  func refreshSubscriptionStatus() async {
    do {
      let customerInfo = try await Purchases.shared.customerInfo()
      isPremium = customerInfo.entitlements["premium"]?.isActive == true
      if !isPremium {
        remainingFreeUses = calculateRemainingUses()
      }
    } catch {
      // エンタイトルメント取得失敗時はオフラインを想定し、最後に確認した状態を使う
      isPremium = UserDefaults.standard.bool(forKey: "cached_premium_status")
    }
  }
 
  func recordUsage() {
    guard !isPremium else { return }
 
    let currentMonth = Calendar.current.component(.month, from: Date())
    let currentYear = Calendar.current.component(.year, from: Date())
    let usageMonthKey = "\(currentYear)-\(currentMonth)"
 
    var usage = getStoredUsage()
 
    // 月が変わっていたらリセット
    if usage["month"] as? String != usageMonthKey {
      usage = ["month": usageMonthKey, "count": 0]
    }
 
    let count = (usage["count"] as? Int ?? 0) + 1
    usage["count"] = count
 
    if let data = try? JSONSerialization.data(withJSONObject: usage) {
      UserDefaults.standard.set(data, forKey: usageKey)
    }
 
    remainingFreeUses = max(0, freeTierLimit - count)
  }
 
  private func calculateRemainingUses() -> Int {
    let currentMonth = Calendar.current.component(.month, from: Date())
    let currentYear = Calendar.current.component(.year, from: Date())
    let usageMonthKey = "\(currentYear)-\(currentMonth)"
 
    let usage = getStoredUsage()
    guard usage["month"] as? String == usageMonthKey else {
      return freeTierLimit // 今月のデータなし = 残回数フル
    }
    let count = usage["count"] as? Int ?? 0
    return max(0, freeTierLimit - count)
  }
 
  private func getStoredUsage() -> [String: Any] {
    guard let data = UserDefaults.standard.data(forKey: usageKey),
          let usage = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
    else { return [:] }
    return usage
  }
 
  // ペイウォールを表示してサブスク購入を促す
  func presentPaywall(from viewController: UIViewController) async {
    do {
      let offerings = try await Purchases.shared.offerings()
      if let offering = offerings.current {
        await MainActor.run {
          // RevenueCat の presentPaywallIfNeeded を使う
          Purchases.shared.presentPaywall(offering: offering, from: viewController)
        }
      }
    } catch {
      print("Offerings fetch failed: \(error)")
    }
  }
}

オフライン時の動作設計も重要です。Purchases.shared.customerInfo() は失敗することがあり、その場合に isPremium = false としてしまうとプレミアムユーザーが機能を使えなくなります。上記のコードでは UserDefaults に最後に確認したステータスをキャッシュし、API エラー時のフォールバックとして使っています。

添削履歴の MMKV ローカル保存と管理

履歴機能はユーザーの継続利用に直結します。「先週添削した英文を確認したい」というニーズは頻繁に発生します。MMKV を使ったローカル保存の実装です(AsyncStorage・MMKV・SQLite の使い分けも参考にどうぞ)。

// Services/HistoryService.swift
import Foundation
 
struct CorrectionEntry: Codable, Identifiable {
  var id: UUID = UUID()
  let originalText: String
  let feedback: String
  let mode: CorrectionMode
  let timestamp: Date
  var isFavorited: Bool = false
 
  // プレビュー用(最初の100文字)
  var textPreview: String {
    String(originalText.prefix(100)) + (originalText.count > 100 ? "…" : "")
  }
}
 
class HistoryService {
  private let historyKey = "correction_history_v2"
  private let maxEntries = 100
 
  func save(entry: CorrectionEntry) {
    var history = loadAll()
    history.insert(entry, at: 0) // 最新を先頭に
    // 上限を超えた分は削除
    if history.count > maxEntries {
      history = Array(history.prefix(maxEntries))
    }
    persist(history)
  }
 
  func loadAll() -> [CorrectionEntry] {
    guard let data = UserDefaults.standard.data(forKey: historyKey),
          let entries = try? JSONDecoder().decode([CorrectionEntry].self, from: data)
    else { return [] }
    return entries
  }
 
  func delete(id: UUID) {
    var history = loadAll()
    history.removeAll { $0.id == id }
    persist(history)
  }
 
  func toggleFavorite(id: UUID) {
    var history = loadAll()
    if let index = history.firstIndex(where: { $0.id == id }) {
      history[index].isFavorited.toggle()
    }
    persist(history)
  }
 
  func clearAll() {
    UserDefaults.standard.removeObject(forKey: historyKey)
  }
 
  // テキストファイルとしてエクスポート
  func exportAsText() -> String {
    let entries = loadAll()
    return entries.map { entry in
      let dateStr = ISO8601DateFormatter().string(from: entry.timestamp)
      return """
      --- \(dateStr) [\(entry.mode.displayName)] ---
      【原文】
      \(entry.originalText)
 
      【フィードバック】
      \(entry.feedback)
      """
    }.joined(separator: "\n\n")
  }
 
  private func persist(_ entries: [CorrectionEntry]) {
    if let data = try? JSONEncoder().encode(entries) {
      UserDefaults.standard.set(data, forKey: historyKey)
    }
  }
}

エクスポート機能は App Store 審査でも好評価を得やすい機能です。「ユーザーが自分のデータをコントロールできる」という観点から、プライバシー面での加点材料になります。実際に審査コメントでこの点を評価されたことがあります。

本番でつまずいた落とし穴3選

実際にリリースした後に気づいた問題を共有します。どれも事前に知っておけば防げたものです。

1. SSE ストリーミングが iOS のバックグラウンド移行で途切れる

ユーザーがホーム画面に移動したりロック画面になったりすると、iOS がネットワーク接続を一時停止します。添削中にこれが起きると、ストリームが中途半端な状態で終了します。対策は URLSession のバックグラウンドタスクではなく、ScenePhase を監視してアプリがバックグラウンドに移行したときに添削をキャンセルし、ユーザーに「再試行してください」を表示する方針にしました。完璧な解決策ではありませんが、中途半端なフィードバックを表示するよりずっと誠実な体験です。

2. Claude のフィードバックが長すぎてスクロールが必要になる

Sonnet は律儀に詳しいフィードバックを返すため、短いテキストでも1,500トークン前後の応答になることがあります。ユーザーが圧倒されないよう、「簡易モード」(主要な修正のみ)と「詳細モード」(完全なフィードバック)を切り替えられるオプションを追加しました。プロンプトに max_length: brief のような指示を加えるだけで調整できます。

3. 月次使用カウントのロールオーバーが端末の時刻依存になる

Date() の月判定をクライアントだけで行うと、端末の時刻を変えることで制限を回避できます。厳密な制限が必要な場合は Worker 側でユーザー識別子(RevenueCat の appUserId)に紐付けて Cloudflare KV にカウントを保存するべきです。ただし、個人開発の初期段階では実装コストが高いため、まずクライアント側の簡易実装で GA して、実際に回避されるようであれば強化するというアプローチで問題ないと感じています。

App Store 審査・プライバシー対策

ライティングコーチアプリは、ユーザーが入力するテキストに個人情報が含まれる可能性があります。プライバシーポリシーとApp Store の Privacy Nutrition Label には以下の点を明記してください。

  • ユーザーが入力したテキスト: Anthropic の API に送信されること、Anthropic のプライバシーポリシーが適用されること
  • データの保存先: 添削履歴はデバイスローカルのみに保存し、サーバーには保存しないこと
  • データ収集: 添削テキストをマーケティングや広告に使用しないこと

審査時に特に確認された点として、「ユーザーが入力したテキストを第三者と共有する際の同意フロー」がありました。アプリ起動時に一度、Claude API 経由で外部にテキストが送信される旨の同意画面を設けることで問題なく通過しました。

プロンプトエンジニアリングについて更に深く学びたい方にはAI プロンプトエンジニアリング完全マスターガイド 2026を読んでいただくと、添削精度を大きく上げるヒントが見つかると思います。

まず1つのモードから始めてみてください

記事が長くなりましたが、全部一気に実装する必要はありません。最初の一歩として、「ビジネスメール添削」モードだけを実装して TestFlight でベータテストに出してみることをおすすめします。1つのモードでも、実際のユーザーにどんな使われ方をするかが分かれば、次に何を作るべきかが見えてきます。

Rork Max + Claude API の組み合わせは、この種の AI コーチングアプリと非常に相性が良いと感じています。ぜひ試してみてください。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

AI モデル2026-05-05
Rork Max で AI 面接コーチアプリを作る:音声録音・Claude 評価・進捗管理の実装ガイド
Rork Maxで音声認識・Claude 4 APIによる回答評価・セッション管理を組み合わせたAI面接コーチアプリを構築する完全ガイド。動作するコード例と収益化戦略まで一気通貫で解説します。
AI モデル2026-05-04
Rork Max SwiftUI で個人アプリ収益を作る完全戦略 2026 — ネイティブ生成と AdMob/サブスクで月収100万円を目指す
Rork Max の SwiftUI ネイティブアプリ生成機能を最大活用し、個人開発者がアプリ収益で月収100万円を目指すための実践戦略。アプリ企画から ASO、AdMob 配置、サブスク導線、リテンション設計までを、12年間の個人アプリ運営経験を持つ筆者が体系化します。
AI モデル2026-04-25
Rork で作るAI音声日記アプリ実装ガイド — Whisper × Claude で毎日の声から感情トレンドを可視化する
Rork でAI音声日記アプリを作る完全ガイド。Whisper APIで文字起こし、Claude APIで感情分析、Victory Nativeでトレンド可視化まで実装。プライバシー設計・サブスク収益化も解説します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →