個人開発でアプリを作り続けるなかで、ストアのコピーや AdMob まわりの文言を「自分の手だけで管理する」のは限界だと感じるようになりました。対応するロケールと文言が増えるほど、書き換える量よりも、書き換えた後に壊れていないか確認する量のほうが、ずっと多くなっていきます。
Claude を使い始めて何が変わったか、と聞かれることが増えました。多くの人は「文章が早く書けるようになった」と答えると思います。私はそうではなく、「文章の型を、人間より厳密に守らせられるようになった」が一番大きい変化だと感じています。その鍵が <output_format> タグでした。本稿は、そこへたどり着くまでに私がやってきた試行錯誤と、いま個人開発アプリのコピー自動化に組み込んでいる実装の中身を書き残したものです。
4年運用してたどり着いた「output_format ファースト」の考え方
最初は単純に、人間が書いた説明文を Claude に投げ、改行や絵文字の整形だけお願いしていました。すると、同じプロンプトでも
急に末尾に「いかがでしたか?」が付く
段落の順序が入れ替わる
ストアのバイト制限を超える
翻訳のはずが英語のままで返ってくる
といった揺れが頻発します。一回や二回なら気にならないのですが、100 言語ぶん回すと、揺れの修正だけで半日が消えていきました。
そこで設計の軸を反転させたのが、<output_format> の徹底でした。Claude には「自由に書いていいよ、ただし出力の型はこれです」と先に渡しておく。人間がプロンプトを読んだら 5 秒で「こういう形が返るんだな」と分かるくらい、最初に型を見せる。指示の本文よりも、型のほうが先に来る順番です。
プロンプトを書くときも同じで、書き出し方を決める前に出力の納まりの形を先に決めてしまうほうが、結果的に直しが少なくなります。設計の順番をひとつ入れ替えるだけで後工程の手戻りが減るというのは、個人開発を続けるなかで何度も実感してきたことです。
文章生成 API は「型」を持たない — そこで何が起きるか
Claude API のレスポンスは、原則としてプレーンテキストの塊です。tool_use や messages.responses で構造化することはできても、それは外側のラッパであって、生成した本文そのものに「ここからは title、ここからは subtitle」という区切りが入るわけではありません。
実運用で壊れる典型を 3 つ書きます。
長さの揺れ : 「150 文字以内で」と書いても、平気で 180 文字になることがあります。
段落順の揺れ : A → B → C で並べてほしい段落が、B → A → C で返ってくる。
言語の揺れ : 多言語生成で、入力に英語のキーワードが混ざると、翻訳されず英語のまま戻ってくる。
これらを「あとからパースして直す」発想で書くと、パーサが正規表現の山になり、保守できなくなります。私は一度それで丸一日溶かしました。<output_format> を入れて以降は、こうした崩れの 8 割が消えました。
output_format タグの最小構成 — 壁紙アプリのストア説明文で使っている例
実際に私が壁紙系アプリで運用しているプロンプトの構造を、固有名詞を抜いた形で書き起こします。
# claude_app_copy.py
import anthropic
client = anthropic.Anthropic()
SYSTEM_PROMPT = """ \
あなたは iOS / Android アプリのストア説明文を、現地言語ネイティブの感覚で \
書き直すアシスタントです。出力は必ず <output_format> で示した構造を厳守し、 \
余計な前置きや感想、絵文字、Markdown装飾を加えてはいけません。 \
"""
USER_TEMPLATE = """ \
<app_meta>
<name> {name} </name>
<category> {category} </category>
<target_locale> {locale} </target_locale>
<max_subtitle_chars>30</max_subtitle_chars>
<max_promotional_chars>170</max_promotional_chars>
<max_description_chars>4000</max_description_chars>
</app_meta>
<source>
{source_text}
</source>
<output_format>
<subtitle>...</subtitle>
<promotional_text>...</promotional_text>
<description>
<hook>...</hook>
<features>
<item>...</item>
<item>...</item>
<item>...</item>
</features>
<closing>...</closing>
</description>
</output_format>
<rejection_rules>
- subtitle が 30 文字を超えたら、自動的に省略形に書き直すこと
- description 内に絵文字・Markdown・URL を含めないこと
- 入力が英語であっても、出力は必ず target_locale の言語で書くこと
- 「いかがでしたか」「以上、解説しました」などの締めを禁止
</rejection_rules>
"""
def run (name: str , category: str , locale: str , source: str ) -> str :
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 4096 ,
system = SYSTEM_PROMPT ,
messages = [{
"role" : "user" ,
"content" : USER_TEMPLATE .format(
name = name, category = category,
locale = locale, source_text = source,
),
}],
)
return resp.content[ 0 ].text
ポイントは 3 つあります。第一に、<output_format> の中身は 実際の出力サンプル ではなく、埋めるべき空欄を示す枠 にすること。空欄を残せば「ここを埋めよ」という指示として強く読まれます。第二に、長さ制限は <app_meta> 側に書き、<rejection_rules> から再び参照すること。同じ制限を 2 箇所で言うと、Claude は「これは厳密な指示だ」と扱います。第三に、<rejection_rules> を独立タグにすること。文中の制約は読み飛ばされやすいのに対し、タグで囲った制約は守られる確率がはっきり上がります。
失敗例を読んで覚える — 出力崩れを防ぐ 5 つのチェックポイント
私がこの構造に至る過程で踏んだ失敗を、Before / After で残します。同じ失敗を踏まないために役立つはずです。
1. 出力の型を本文中に書いていた
Before:
以下のフォーマットで返してください:
subtitle: ...
promotional_text: ...
description: ...
After:
<output_format>
<subtitle>...</subtitle>
<promotional_text>...</promotional_text>
<description>...</description>
</output_format>
文中で「以下のフォーマット」と書くより、タグで囲ったほうが守られる確率が 3 倍ほど高い印象です。Claude は本文よりタグで囲った領域を「形を保つ場所」として扱う傾向が強いと感じます。
2. 制約をひとつのタグに混ぜていた
長さも禁則も全部 <output_format> の中に書くと、出力サンプルなのか守るべきルールなのか区別がつかなくなります。<app_meta> と <rejection_rules> を分離してから、長さ違反が 20% から 3% 以下に下がりました。
3. 禁止語句を「使わないでください」と書いていた
否定形は弱いと感じます。「いかがでしたか を含めないこと」と書くより、<rejection_rules> の中に並べたほうが守られます。お願いではなく宣言として読ませる形が効きました。
4. 入力ソースを直接渡していた
<source>...</source> で囲っただけで、編集スタイルや混入した英語キーワードに引きずられる確率がはっきり下がります。「ここから先は素材、加工対象」と Claude に明示することで、ソースの語彙が出力に染み出さなくなります。
5. max_tokens を盛りすぎていた
max_tokens を 8192 のように大きく取ると、結果的に Claude が「もっと書いていい」と判断して冗長になりました。description の上限が 4000 文字なら、max_tokens は 2500 程度に抑えるほうが、文体が引き締まります。これは公式ドキュメントには書かれていない、実運用で気づいたチューニングです。
並列バッチでの一貫性 — 100 言語の説明文を 1 度に流す時の落とし穴
App Store Connect は 100 を超えるロケールを抱えており、ひとつのアプリを世界展開しようとすると、ストア説明文だけで 100 本以上のコピーが必要になります。私は朝のオフピーク帯(JST 02 時台)にバッチで流していますが、ここで <output_format> を持っていない設計は破綻します。
並列実行で起きる典型を 3 つ書きます。
# 危険な書き方
locales = [ "ja" , "en" , "ko" , "zh-Hans" , "de" , "fr" , ... ] # 100+ items
results = await asyncio.gather( * [run_async(loc) for loc in locales])
第一に、各レスポンスのフォーマットが微妙にずれます。<subtitle> を返すロケールと、subtitle: で返すロケールが混ざる。第二に、長さの揺れがロケール単位で出ます。日本語だと 28 文字でぴったり、ドイツ語だと 35 文字で溢れる、といった現象が普通に起きます。第三に、内容が薄いロケールほど Claude は補完しようとして、嘘の機能名や架空の対応 OS バージョンを書きます。
私が運用している対策は次のとおりです。
プロンプトに 入力ロケールの言語名と「最近のストア審査傾向」のヒント を一文だけ追記します。たとえば韓国語なら「ストアの記述で誇大表現が審査ではじかれやすいので、形容を控えめに」と書くだけで、はじかれる確率が下がります。
各ロケールごとに max_tokens を 言語別に変える 。ドイツ語・フィンランド語など語数が膨らむ言語は 3500、日本語・中国語は 2000 程度。
出力後に 自前のバリデータ で <subtitle> の長さと禁則語の有無を機械的に再検証し、違反したロケールだけ自動で再生成します。
このバリデータは 50 行ほどの Python ですが、これが入ってから App Store Connect の差し戻しが従来の 1/4 になりました。差し戻しは 1 件あたり数日のリードタイムを失うので、年間で見ると 40 日ぶんの作業時間が浮いた計算です。個人開発者にとって 40 日は大きい。
App Store Connect 側の運用に組み込む流れ
iOS の App Store Connect には、ローカライズごとに subtitle / promotional_text / description のフィールドがあります。私はここを 手で触らない 運用にしています。バッチが回ったら、結果は一旦 Cloudflare R2 に書き出し、レビュー専用のダッシュボードで眺めてから、fastlane deliver で一括反映します。
# パイプライン全体
def run_pipeline (app_id: str , locales: list[ str ]) -> None :
source = load_source_copy(app_id)
drafts = {loc: generate_copy(app_id, loc, source) for loc in locales}
for loc, draft in drafts.items():
if not validate(draft):
drafts[loc] = regenerate(app_id, loc, source, hint = "長さ違反を修正" )
write_to_r2(app_id, drafts)
# 人間が確認してから fastlane deliver
ここで重要なのは、Claude の出力を そのまま fastlane に流さない という設計判断です。たとえ <output_format> で型が固まっていても、ストアに公開される文言は最終的に自分の目で必ず通します。技術にすべてを任せきるのではなく、最後の確認だけは手の届く範囲に残しておく、というのが個人開発を続けるなかで定まった私のやり方です。
プロンプトの中で「許容しない出力」を宣言する
<rejection_rules> の書き方には小さなコツがあります。さまざまな審査基準やスタイルガイドを読み比べていて気づいたのですが、優れたガイドラインは「やってはいけないこと」を 具体的な動詞 で書きます。「失礼な表現は避けること」ではなく、「読者を見下す呼びかけを使わないこと」のように、行為を限定します。
Claude へのプロンプトでも同じです。
<rejection_rules>
- 「いかがでしたか」「以上、解説しました」で終わらせない
- 絵文字(U+1F300 以降の領域)を含めない
- "Click here", "詳しくはこちら" のような URL 前提の誘導を含めない
- 制限文字数を 1 文字でも超えたら、自動で削って収めること
- 言語切り替えの混在を許さない(英語キーワードが入力にあっても、出力は必ず target_locale)
</rejection_rules>
抽象的な「丁寧に書いてください」より、具体的な禁止動詞のほうが守られます。これは Anthropic の公式ガイドにも書かれていますが、実運用では「禁止動詞 + 違反時の自動補正手順」をセットで書くと、再生成の回数がさらに減ります。
レビューループを短くする小さな工夫
私はバッチを走らせた後、各ロケールのドラフトを Markdown のテーブルではなく、 <diff> 形式でレビューできるダッシュボードを Cloudflare Pages で作っています。前回ストア配信したコピーと、今回 Claude が生成したコピーの差分だけが目に入る形にしてあります。
[ja]
- 旧 promotional_text: 今だけ無料!壁紙が選び放題。
+ 新 promotional_text: 静かな夜にひらく、和紙質感の壁紙コレクション。
[en]
- 旧 promotional_text: Free wallpapers — best collection!
+ 新 promotional_text: A quiet evening companion: handpicked papercraft wallpapers.
このダッシュボードは推奨というより、私自身が個人開発者として絶対に必要だと感じた仕組みです。AdMob の eCPM やリテンションは差分で追うのが普通ですが、ストアのコピーも差分で追うようにしたら、何が効いてダウンロードが伸びたか・落ちたかが、肌で見えるようになりました。これは 12 年やってきて、ようやくたどり着いた運用です。
エラー時の挙動を「型を保ったまま」設計する
Claude API が断続的にエラーを返した場合、リトライ前提の設計が必要です。ここでも <output_format> を握っておくと、リトライ時に「前回失敗した部分だけ再生成する」ことができます。
def regenerate_partial (app_id: str , locale: str , broken_field: str ) -> dict :
"""壊れたフィールドだけ Claude に書き直してもらう"""
prompt = f """ \
<previous_output>
{ json.dumps(load_draft(app_id, locale), ensure_ascii = False , indent = 2 ) }
</previous_output>
<broken_field> { broken_field } </broken_field>
<instruction>
broken_field で指定されたフィールドだけを書き直してください。
他のフィールドは絶対に変更しないでください。
</instruction>
<output_format>
< { broken_field } >...</ { broken_field } >
</output_format>
"""
# ... 呼び出し
全体を作り直すのではなく、壊れた部分だけを差し替える戦略は、トークン代の節約にも、出力の安定にも効きます。私はこれを「部分書き直しパターン」と呼んでいて、ストアコピーだけでなく、AdMob の文言管理や、アプリ内のチュートリアル文にも応用しています。
終わりに — 出力の型を作ることは、自分の作品を守ること
<output_format> タグの話は、技術的には小さなテクニックです。けれども 4 年運用してみて、これが個人開発者にとって持つ意味は、技術以上のものがありました。
私は作品そのものの制作には AI を使っていませんが、その作品やアプリを世界中の人へ届けるためのストア説明文や ASO は、AI に支えてもらっています。出力の型を作ることは、届けたいものの声を崩さずに守ることだと、私は考えています。
明日読み返すなら、まず手元で動いているプロンプトを 1 本だけ取り出して、本文と禁則を別タグに分けるところから始めるのが現実的です。たったそれだけで、出力の崩れ方が変わって見えるはずです。お読みいただきありがとうございました。