エンジニアリンググループ ゼネラルマネージャーの横本(@yokomotod)です。
このブログはSREチームブログリレー4日目の記事です。 昨日は山本さんによるSRE作業もGemini CLIで効率化する記事でした。
続けて今日もAIコーディング関連、Claude CodeのSDKが気になって触ってみた知見を紹介します。
言わずもがなClaude Codeは強力なツールで、最近はHooksなども登場し、拡張性もどんどん強化されています。 しかし、まだまだもっと自由に機能強化して「オレの最強のClaude Code」を作ってみたいですよね。
というわけで、Claude Code SDKを使えばそういうことも出来るのかな? と思って遊んでみました。
ソースコードはこちらでも公開しています。
Claude Code SDK
公式ドキュメントはこちら
現時点で、コマンドライン、TypeScript、Pythonが提供されています。
コマンドライン
「SDK」というとなんかいろいろ出来そう/でもちょっと複雑そうという気になりますが、Claude Codeの場合とてもシンプルで
コマンドラインSDK = claude -p (--print)
通常起動の「対話モード」ではなく「単発実行して結果を出力するモード」のこと、という位置づけのようです。
はい、もうこれでClaude Code SDKの9割を理解したと言っても過言ではないかもしれません。
TypeScript、PythonのSDKも、内部で claude -p を起動していて、ライブラリは入出力をサポートしてくれる薄いラッパーになっていそうです。
TypeScript
公式ドキュメントからサンプル
import { query, type SDKMessage } from "@anthropic-ai/claude-code"; const messages: SDKMessage[] = []; for await (const message of query({ prompt: "foo.pyについての俳句を書いて", abortController: new AbortController(), options: { maxTurns: 3, }, })) { messages.push(message); } console.log(messages);
TypeScript SDKはClaude Code本体 @anthropic-ai/claude-code と一体化して提供されています。
本体コードとセットになっている分、最新機能への対応などは早いことが期待できそうです。
一方で、型ファイル(.d.ts)は提供されてますが*1、 本体コードと合わせてソースは非公開なので、実装や挙動の調査は難しくなっています*2。
Python
公式ドキュメントからサンプル
import anyio from claude_code_sdk import query, ClaudeCodeOptions, Message async def main(): messages: list[Message] = [] async for message in query( prompt="foo.pyについての俳句を書いて", options=ClaudeCodeOptions(max_turns=3) ): messages.append(message) print(messages) anyio.run(main)
嬉しいことに、Python SDKはソースコードが公開されています。
そのためSDKがどんなことをやっているか完全に把握できて、claude --printをサブプロセスで実行しているのがコードで見て取れます。
この記事では、ソースが確認できて実装に確信が持てるPython SDKを使ってみます。
対話型CLIを再発明してみる
まず、 実質 claude -p なSDKを使って通常起動 claude のような対話型CLIを作ってみます。
まぁそれだけだと単なる劣化版Claude Codeが出来上がるだけですが、拡張していけばオリジナルClaude Codeが作れそうです。
import readline # for better input import anyio from claude_code_sdk import ( AssistantMessage, SystemMessage, TextBlock, query, ClaudeCodeOptions, ) async def main(): last_session_id = None while True: user_input = input("> ") if user_input.lower() == "/exit": break if not user_input.strip(): continue options = ClaudeCodeOptions( permission_mode="bypassPermissions", # 注意: 乱暴に全許可 resume=last_session_id, # 前回session_idをresume ) async for message in query(prompt=user_input.strip(), options=options): print(f"[debug] {message}") if isinstance(message, SystemMessage): last_session_id = message.data["session_id"] # queryのたびに新しいsession_idが発行される if isinstance(message, AssistantMessage): print("● ", end="") for block in message.content: if isinstance(block, TextBlock): print(block.text, end="") print() anyio.run(main)
ほとんどPython SDK公式サンプルを while - input ループで囲っただけです。
が、1つ重要な箇所として resume=last_session_id している点があります。
claude -p を毎回サブプロセスで起動 = 毎回新規claudeプロセスになるので、resumeしてあげないと直前の会話を何も知らないことになってしまいます。
resumeである程度解消できますが、残る問題もあることについて最後の考察で紹介します。

ここでは簡単のためにTextBlockのみ整形して残りはdebug printでサボっていますが、ツール呼び出しや呼び出し結果、全体Result(呼び出し料金なんかが見えます)も、もちろん出力可能です*3。
Webアプリにしてみる
今度はWebからClaude Codeを操作できるようにしてみます。
ちなみに既に同様の試みをされている記事もあります。
(なお言うまでもなく、本当にこのままアプリ公開したら誰でも遠隔コード実行し放題になるのでダメゼッタイ)
バックエンド: FastAPI
FastAPIを使うことにします。
$ uv add fastapi uvicorn[standard]
まずエントリポイント周辺です。
非同期で claude -p の出力を返してくれるSDKを活かすために、フロントエンドとの通信にSSEを使うことにします。
StreamingResponse を使うと簡単に実装できます。
import json from collections.abc import AsyncIterator from dataclasses import asdict from claude_code_sdk import AssistantMessage, ClaudeCodeOptions, TextBlock, query from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["POST"], ) class Payload(BaseModel): prompt: str last_session_id: str | None @app.post("/api/chat") async def chat( payload: Payload, ) -> StreamingResponse: return StreamingResponse( generate_response(payload.prompt, payload.last_session_id), media_type="text/event-stream", )
SDKを呼び出す部分です。
async def generate_response( prompt: str, last_session_id: str | None ) -> AsyncIterator[str]: options = ClaudeCodeOptions( permission_mode="bypassPermissions", # 注意: ここでは全許可 resume=last_session_id, # 前回session_idをresume ) async for message in query(prompt=prompt, options=options): print(f"[debug] {message}") data = asdict(message) data["type"] = type(message).__name__ if isinstance(message, AssistantMessage): for i, block in enumerate(message.content): if isinstance(block, TextBlock): block_data = asdict(block) block_data["type"] = type(block).__name__ data["content"][i] = block_data yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
ここで困ったことに、query() から返される Message や ContentBlock がクラス名以外に判定材料を持っていません。
対処として、今回は簡単にクラス名を type フィールドとして追加しています。
以上でバックエンド完成。
$ uv run uvicorn api:app --reload --port 8000
で起動します(開発モードです)。
フロントエンド: React
viteでセットアップ。
$ npm create vite@latest frontend -- --template react-ts $ cd frontend $ npm install @microsoft/fetch-event-source tailwindcss @tailwindcss/vite @tailwin dcss/typography react-markdown remark-gfm # tailwindセットアップ (vite.config.ts, index.css) # https://tailwindcss.com/docs/installation/using-vite
API呼び出し
SSE受信側も @microsoft/fetch-event-source を使うことで簡単に出来ます(エラーハンドリングをサボっていますが)。
loading状態などもまとめて1つのHookにしています。
import { fetchEventSource } from "@microsoft/fetch-event-source"; type UserMessage = { type: "UserMessage"; content: string }; type AssistantMessage = { type: "AssistantMessage"; content: { type: string; text?: string; }[]; }; type Message = UserMessage | AssistantMessage; function useChat() { const [lastSessionId, setLastSessionId] = useState<string | null>(null); const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const sendMessage = async () => { setIsLoading(true); const userMessage: Message = { type: "UserMessage", content: input }; setMessages((prev) => [...prev, userMessage]); const currentInput = input; setInput(""); await fetchEventSource(`//localhost:8000/api/chat`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt: currentInput, last_session_id: lastSessionId || null, }), onmessage(ev) { const data = JSON.parse(ev.data); if (data.session_id) { setLastSessionId(data.session_id); return; } if (data.type !== "AssistantMessage") { return; } setMessages((prev) => [...prev, data]); }, }); setIsLoading(false); }; return { messages, input, setInput, isLoading, sendMessage }; }
ビュー
useChat Hookに切り出したので表示部分はすっきりしました。
(ここで実際のソースコードにはtailwindのclassnameがありますが、圧倒的に目が滑り出すので省略しています)
function App() { const { messages, input, setInput, isLoading, sendMessage } = useChat(); return ( <div> <header> <div> <h1>Claude Code Web</h1> </div> </header> <div> {messages.map((msg, idx) => msg.type === "UserMessage" ? ( <div key={idx} > {msg.content} </div> ) : ( <AgentMessageItem key={idx} message={msg} /> ), )} </div> <div> <div> <textarea value={input} onChange={(e) => setInput(e.target.value)} disabled={isLoading} rows={1} placeholder="入力..." /> <button onClick={sendMessage} disabled={isLoading || !input.trim()} > {isLoading ? "送信中..." : "送信"} </button> </div> </div> </div> ); }
AssistantMessage の表示では、 react-markdown, remark-gfm, @tailwindcss/typography を使ってマークダウン形式にも対応してみました。
function AgentMessageItem({ message }: { message: AssistantMessage }) { return ( <div> {message.content .filter(({ type }) => type === "TextBlock") .map((block, blockIdx) => ( <div key={blockIdx}> <ReactMarkdown remarkPlugins={[remarkGfm]}> {block.text} </ReactMarkdown> </div> ))} </div> ); }
npm run dev で起動します。
完成!

考察: SDK = claude -p
前半「SDK = claude -p で、9割理解完了」と書きましたが、最後に、実際に作って動かしてみたわかった「残り1割」を紹介します。
対話的に動作許可を与える方法
紹介コードでは permission_mode="bypassPermissions" としているところを、本家Claude Codeのようにインタラクティブに実行可否を指示するためには、MCPを用意してハンドリングが必要なようです。
Edit 時に、read済みというフラグが消える
毎回 claude プロセス起動、による課題
呼び出しのたびに別プロセスになる対策としてresumeするようにしましたが、それでも次のような課題が残ります
既に読み込み済みのファイルを、ターンを跨いで編集しようとすると
File has not been read yet. Read it first before writing to it.
というエラーをEdit Toolが返します。
Claude Codeはファイルを編集する際に「内容を読み込み済みなこと」「読み込み時のタイムスタンプから更新されていないこと」をきちんとチェックしているようで、この判定(メモリ上のデータ)がプロセスごとにリセットされていそうです。
エラーは表面化はせず、Claudeが自分で(同一プロセス内で)再度ファイルReadしてからEditしてくれますが、コンテキストがどうなっているかも心配です。
(TypeScript SDKならサブプロセスに分かれてなくて大丈夫だったりしないか…と期待したんですが、同じ症状でした。無念)
ストリーミングJSON入力の期待
上記の問題に関して、
stdin経由で提供されるメッセージのストリームで、各メッセージはユーザーターンを表します。これにより、claudeバイナリを再起動することなく会話の複数ターンが可能になり、リクエストを処理している間にモデルにガイダンスを提供できます。
というとても気になる記述があります。
が、Python SDKコード、TypeScriptの.d.tsを読んだ感じ対応されていない気配で、今後対応されて解決することを期待しています。
おわり
以上、Claude Code SDKで遊んでみた&Claude Codeの裏側もちょっと見えてきた、という記事でした!
SDKを使う際の参考になったり、SDK使ってなにか作ってみたくなって頂けたら嬉しいです。
We're Hiring
エムスリーでは、使っているツールの裏側にダイブせずにはいられないギークな仲間を募集しています!
エンジニア採用ページはこちら
エンジニア新卒採用サイトもオープンしました!
インターンもこちらから。常時募集しています!
カジュアル面談もお気軽にどうぞ
*1:https://www.npmjs.com/package/@anthropic-ai/claude-code/v/1.0.53?activeTab=code
*2:この点、まるごとオープンなGemini CLIはありがたいですね https://cloud.google.com/blog/ja/topics/developers-practitioners/introducing-gemini-cli/ 「Gemini CLI は完全にオープンソース(Apache 2.0)」
*3:手前味噌ですが、Thinking時の出力が正しくパース出来てないバグを発見してPullRequestを出してみています https://github.com/anthropics/claude-code-sdk-python/pull/28