パイプラインを作った。claude -p "このファイルから設定値を取り出してJSONで返して" を実行する。テストでは動く。本番では、こんな出力が返ってくる:
以下が見つかった設定値です:
```json
{
"database_url": "postgres://...",
"api_key": "...",
なお、max_connections フィールドは2箇所に現れています…
{ “database_url”: “postgres://…”,
JSONパーサーが例外を投げる。パイプラインが落ちる。最初からやり方が間違っていたのかと思い始める。
間違っていた。実際に何が起きているのか、どう直すのかを説明する。
---
## なぜ claude -p の出力がJSONパーサーを壊すのか
`claude -p` フラグはClaude Codeを「プリント」モードで実行する — 非インタラクティブなセッションで、stdoutに出力してから終了する。スクリプティング用に設計されている。しかし、デフォルトの出力フォーマットは*人間が読めるテキスト*であり、機械が読むJSONではない。
「JSONを返して」と自然言語で頼むとき、言語モデルに対してレスポンスのフォーマットを求めている。大抵は従う。しかし、パーサーに届くまでにいくつかの問題が出力を壊す可能性がある:
### 問題1: 会話的な前置き
Claudeはデフォルトで会話型モデルだ。明示的な出力フォーマット指定がなければ、プロンプトを関数呼び出しではなく会話として扱う。つまり、求めたJSONが以下で囲まれる可能性がある:
- 導入文(「以下がリクエストされた値です:」)
- JSON後の説明コメント
- JSONブロックをトリプルバッククォートで囲んだインラインマークダウン
これらはすべて `json.loads()` を壊す。
### 問題2: 拡張思考のリーク
Claudeの拡張思考機能は、最終レスポンスの前に推論トークンを生成する。ほとんどの設定では、思考トークンは出力前にストリップされる。しかし、一部のストリーミング設定や古いCLIバージョンでは、思考出力がレスポンスと一緒にstdoutにリークすることがある。
リークした思考ブロックはこのような見た目になる:
{ “database_url”: “postgres://…” }
パーサーは `<thinking>` を無効なJSONとして認識し、即座に失敗する。
### 問題3: ストリーミングのアーティファクト
Claude APIはレスポンスをトークン単位でストリーミングする。`claude -p` CLIはこのストリームをstdoutに書き込む前に再組み立てする — しかし再組み立てしているのは*テキスト*であり、構造化データではない。改行処理、バッファリング、ターミナルエンコーディングのすべてが、特定の環境で有効なJSONをパース不能にするアーティファクトを引き込む可能性がある。
これは非決定論的なので最悪のカテゴリだ。開発環境(自分のターミナル、自分のロケール、自分のバッファサイズ)では問題なく見えて、本番環境(別のOS、別のPythonバージョン、別のstdoutエンコーディング)では失敗する。
---
## 解決策: --output-format json
Claude CodeのCLIには `--output-format` フラグがあり、3つのオプションがある:
- `text` — 人間が読めるテキスト(デフォルト)
- `json` — 構造化JSONエンベロープ
- `stream-json` — ストリーミングコンシューマー向けの改行区切りJSON
スクリプティング用途には `--output-format json` が正解だ。レスポンス全体をJSONエンベロープで包む:
```bash
claude -p "データベース設定を抽出してJSONオブジェクトとして返して" \
--output-format json
出力:
{
"type": "result",
"subtype": "success",
"cost_usd": 0.0023,
"duration_ms": 1847,
"result": "{\n \"database_url\": \"postgres://localhost:5432/prod\",\n \"max_connections\": 100,\n \"ssl_mode\": \"require\"\n}",
"session_id": "sess_01abc..."
}
result フィールドにはモデルのレスポンスが文字列として含まれる。JSONを返すよう頼んだなら、その文字列にJSONが入っている。外側のエンベロープを先にパースしてから result をパースする。
これで会話的な前置き問題が完全に解決するわけではない — Claudeは result の中でJSONを説明テキストで包むかもしれない。それはプロンプトで対処する必要がある。
クリーンなJSONを返すプロンプトの書き方
--output-format json フラグが外側のエンベロープを担当する。内側のコンテンツはプロンプトが担当する。
確実にJSONを返すプロンプトとそうでないプロンプトの違いは、出力フォーマットをどれだけ具体的に指示するかだ:
信頼できない:
このファイルから設定値を取り出してJSONを返して。
信頼できる:
以下のファイルから設定値を取り出してください。
他のテキスト、説明、マークダウン書式を一切含まない、JSONオブジェクトのみを返してください。
JSONオブジェクトは以下の正確なキーを持つ必要があります: database_url, max_connections, ssl_mode。
値が見つからない場合は null を使用してください。
バッククォート、コードブロック、JSONオブジェクトの外側のテキストは一切含めないでください。
重要なフレーズ:
- 「他のテキストを一切含まない、JSONオブジェクトのみを返してください」
- 「バッククォート、コードブロック、JSONオブジェクトの外側のテキストは一切含めないでください」
- 型とnullの挙動を含む明示的なフィールド名
冗長に聞こえる。実際そうだ。しかし、動くパイプラインと、再現が難しい方法で3%の確率で失敗するパイプラインの違いでもある。
バリデーションとリトライを含むPython実装
本番対応のラッパーで、パース、バリデーション、リトライを処理する:
import json
import subprocess
import time
from typing import Any, Optional
from pydantic import BaseModel, ValidationError
class ClaudeJSONResult(BaseModel):
"""外側の claude --output-format json エンベロープ用Pydanticモデル。"""
type: str
subtype: str
cost_usd: float
duration_ms: int
result: str
session_id: Optional[str] = None
def run_claude_json(
prompt: str,
max_retries: int = 3,
retry_delay: float = 2.0,
max_turns: int = 1,
) -> dict[str, Any]:
"""
--output-format json で claude -p を実行し、パース済みの内側の結果を返す。
Args:
prompt: Claudeに送るプロンプト
max_retries: パース失敗時のリトライ回数
retry_delay: リトライ間の待機秒数
max_turns: 最大会話ターン数(シングルショット用デフォルト1)
Returns:
ClaudeのレスポンスからパースされたJSONのdict
Raises:
ValueError: 全リトライ後も出力をパースできない場合
RuntimeError: claude CLIがゼロ以外の終了コードを返した場合
"""
cmd = [
"claude",
"-p", prompt,
"--output-format", "json",
"--max-turns", str(max_turns),
]
last_error: Optional[Exception] = None
for attempt in range(max_retries):
if attempt > 0:
time.sleep(retry_delay)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
)
if proc.returncode != 0:
raise RuntimeError(
f"claude が終了コード {proc.returncode} で終了: {proc.stderr}"
)
# 外側のJSONエンベロープをパース
outer = ClaudeJSONResult.model_validate_json(proc.stdout)
if outer.subtype != "success":
raise ValueError(f"Claudeが非successのsubtypeを返した: {outer.subtype}")
# 内側の結果から偶発的なマークダウンフェンスを除去
inner_text = strip_markdown_fences(outer.result.strip())
# 内側のJSON(Claudeが実際に返したもの)をパース
return json.loads(inner_text)
except (json.JSONDecodeError, ValidationError) as e:
last_error = e
continue
raise ValueError(
f"{max_retries}回の試行後にClaude JSONの出力をパースできなかった。"
f"最後のエラー: {last_error}"
)
def strip_markdown_fences(text: str) -> str:
"""
ClaudeがJSONをマークダウンコードフェンスで囲んでいた場合に除去する。
```json ... ``` と ``` ... ``` のパターンに対応。
"""
lines = text.splitlines()
if not lines:
return text
# 開始フェンスを除去
first_line = lines[0].strip()
if first_line.startswith("```"):
lines = lines[1:]
# 終了フェンスを除去
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines)
使用例:
prompt = """
以下の設定ファイルの内容からデータベース設定を取り出してください。
他のテキスト、説明、マークダウンを一切含まない、JSONオブジェクトのみを返してください。
使用するキー: database_url, max_connections, ssl_mode, port。
値が見つからない場合は null を使用してください。
設定ファイル:
---
DATABASE_URL=postgres://user:pass@localhost:5432/prod
MAX_CONNECTIONS=100
SSL_MODE=require
---
"""
try:
config = run_claude_json(prompt)
print(f"Database URL: {config['database_url']}")
print(f"Max connections: {config['max_connections']}")
except ValueError as e:
print(f"パース失敗: {e}")
except RuntimeError as e:
print(f"Claude CLI エラー: {e}")
スキーマバリデーションの追加
上記のリトライロジックはJSONパースエラーをキャッチするが、セマンティックエラーはキャッチしない — Claudeは期待するスキーマに一致しない有効なJSONを返すかもしれない。内側の結果にもPydanticバリデーションを追加する:
from pydantic import BaseModel, field_validator
from typing import Optional
class DatabaseConfig(BaseModel):
database_url: str
max_connections: int
ssl_mode: str
port: Optional[int] = None
@field_validator("max_connections")
@classmethod
def max_connections_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("max_connections は正の値である必要がある")
return v
@field_validator("ssl_mode")
@classmethod
def ssl_mode_valid(cls, v: str) -> str:
valid_modes = {"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
if v not in valid_modes:
raise ValueError(f"ssl_mode は {valid_modes} のいずれかである必要がある")
return v
def extract_database_config(config_text: str) -> DatabaseConfig:
prompt = f"""
以下のテキストからデータベース設定を取り出してください。
他のテキスト、説明、マークダウンを一切含まない、JSONオブジェクトのみを返してください。
必須キー: database_url (文字列), max_connections (整数), ssl_mode (文字列)。
オプションキー: port (整数またはnull)。
テキスト:
---
{config_text}
---
"""
raw = run_claude_json(prompt)
return DatabaseConfig.model_validate(raw)
Claudeが有効なセット外の ssl_mode を返した場合、Pydanticは即座に ValidationError を発生させる。run_claude_json のリトライループがそれをキャッチしてリトライする。リトライ時、Claudeはサンプリングがわずかに異なるため自己修正することが多い。
このパターン — パース失敗だけでなくバリデーション失敗でもリトライ — が、デモレベルの統合と本番レベルの統合を分ける。
stream-json フォーマットの処理
長時間実行するClaudeタスクで出力を段階的に処理する必要がある場合、--output-format stream-json は改行区切りのJSONを出力する。各行がストリーミングイベントを表すJSONオブジェクトだ。
import json
import subprocess
from typing import Generator
def stream_claude_events(prompt: str) -> Generator[dict, None, None]:
"""claude --output-format stream-json からパースされたイベントをyieldする。"""
cmd = [
"claude",
"-p", prompt,
"--output-format", "stream-json",
"--max-turns", "1",
]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
# 部分的な行や非JSON出力 — スキップ
continue
proc.wait()
if proc.returncode != 0:
stderr = proc.stderr.read()
raise RuntimeError(f"claude が終了コード {proc.returncode} で終了: {stderr}")
def extract_result_from_stream(prompt: str) -> str:
"""ストリーミングモードでclaudeを実行し、最終結果テキストを返す。"""
result_text = ""
for event in stream_claude_events(prompt):
event_type = event.get("type")
if event_type == "assistant":
# assistantメッセージのコンテンツからテキストを取り出す
for block in event.get("message", {}).get("content", []):
if block.get("type") == "text":
result_text += block.get("text", "")
elif event_type == "result":
# 最終resultイベント — 利用可能ならresultフィールドを使う
if event.get("subtype") == "success":
result_text = event.get("result", result_text)
break
return result_text
stream-jsonフォーマットが有用な場面:
- Claudeがマルチターン作業をしていて進捗更新が欲しい場合
- タスクが長時間実行されていてストールを検知したい場合
- Claudeの進捗をリアルタイムで表示するUIを作っている場合
シンプルな抽出タスクには --output-format json の方がすっきりする。実際にストリーミング動作が必要な場合にstream-jsonを使う。
環境とバージョンの考慮事項
「ローカルでは動くのにCIで壊れる」という失敗を引き起こす、JSON出力に特有のいくつかの事項がある:
Pythonのエンコーディング。 一部のLinuxシステムでは、subprocessのテキスト出力のデフォルトエンコーディングはUTF-8ではなくASCIIだ。Claudeのレスポンスに非ASCII文字が含まれていると(分析しているコンテンツ内に、JSONキーだけでなく)、UnicodeDecodeError が発生する。subprocess.run() に encoding="utf-8" を明示的に渡すことで修正できる。
Claude Codeのバージョン固定。 JSONエンベロープのフォーマットはClaude Codeのバージョン間で変わってきた。上記の例にある type、subtype、result フィールド構造は2026年中頃時点で最新だ。古いバージョンを使っている場合、フィールド名が異なる可能性がある。CIでは npm install -g @anthropic-ai/claude-code@X.Y.Z でClaude Codeのバージョンを固定する。
ANTHROPIC_API_KEYのスコープ。 claude -p コマンドは環境変数の ANTHROPIC_API_KEY を使用する。CIではこれを明示的に設定する必要がある。ローカル開発では、Claude Codeは通常インタラクティブなセットアップ時に保存している。失敗パターンは、期待されるエンベロープの代わりに非JSONエラーメッセージをstdoutに出力するサイレントな認証エラーで、パーサーを混乱させる形で壊す。
APIを直接使う場合は?
構造化出力を確実に必要とする本番ワークロードでは、CLIをシェルで呼び出すのではなく、response_format パラメータを使ってAnthropicのAPIを直接呼び出すことを検討する。
APIの構造化出力モードはJSONスキーマに準拠したレスポンスを保証する:
import anthropic
import json
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": "データベース設定を取り出してください。キー: database_url, max_connections, ssl_mode のJSONを返してください。"
}]
)
# プロンプトに従いレスポンステキストはクリーンなJSONのはず
config = json.loads(response.content[0].text)
CLIアプローチ(claude -p)が適切な場面:
- Claude CodeのファイルシステムコンテキストがClaude Codeのファイルシステムコンテキストが必要なスクリプト(プロジェクト内のファイルを読む)
- Claude CodeのツールUse機能(Bash、Edit、Read)を使うワークフロー
- エンジニアが使っているものと同じClaude Codeセッションが欲しいタスク
APIアプローチが適切な場面:
- ファイルシステムアクセスが不要な純粋なテキスト処理
- シェルプロセスのオーバーヘッドが問題になる大量処理パイプライン
- レスポンス構造の保証が必要な本番サービス
ほとんどの自動化タスクには、--output-format json を使ったCLIが正しい出発点だ。出力フォーマットと繰り返し格闘していることに気づいたら、それはAPIに切り替えるサインだ。
Claude Codeのパーミッションとこれが利用可能なツールについての全体像は Claude Code Best Practices: The Official and Community-Tested Guide for 2026 を参照。
クイックリファレンス
# 基本的なJSON出力
claude -p "your prompt" --output-format json
# ターン数制限付き(シンプルな抽出でマルチターンを防ぐ)
claude -p "your prompt" --output-format json --max-turns 1
# 長時間タスク用ストリーミングJSON
claude -p "your prompt" --output-format stream-json
# Pythonでの結果パース(シンプルなケースのワンライナー)
python3 -c "
import json, subprocess
out = subprocess.run(['claude', '-p', 'return {\"ok\": true}', '--output-format', 'json'], capture_output=True, text=True)
envelope = json.loads(out.stdout)
result = json.loads(envelope['result'])
print(result)
"
本番で機能するパターン:
- 外側のエンベロープに
--output-format json - 内側のJSONフォーマットに明示的なプロンプト指示
- 安全網として
strip_markdown_fences() - パース済み結果へのPydanticバリデーション
json.JSONDecodeErrorとValidationError両方でリトライ
Claudeのクリーンな出力は、2層の問題として扱えば信頼できる: CLIの出力フォーマット(フラグで修正)とモデルのレスポンスフォーマット(プロンプトで修正しスキーマでバリデーション)。両方の層を処理すれば失敗は消える。