エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Claude Code SDKでClaude Code Webを作ってみる

エンジニアリンググループ ゼネラルマネージャーの横本(@yokomotod)です。

このブログはSREチームブログリレー4日目の記事です。 昨日は山本さんによるSRE作業もGemini CLIで効率化する記事でした。

www.m3tech.blog

続けて今日もAIコーディング関連、Claude CodeのSDKが気になって触ってみた知見を紹介します。

言わずもがなClaude Codeは強力なツールで、最近はHooksなども登場し、拡張性もどんどん強化されています。 しかし、まだまだもっと自由に機能強化して「オレの最強のClaude Code」を作ってみたいですよね。

というわけで、Claude Code SDKを使えばそういうことも出来るのかな? と思って遊んでみました。

ソースコードはこちらでも公開しています。

github.com

Claude Code SDK

公式ドキュメントはこちら

docs.anthropic.com

現時点で、コマンドライン、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はソースコードが公開されています。

github.com

そのため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である程度解消できますが、残る問題もあることについて最後の考察で紹介します。

見た目はclaude code

ここでは簡単のためにTextBlockのみ整形して残りはdebug printでサボっていますが、ツール呼び出しや呼び出し結果、全体Result(呼び出し料金なんかが見えます)も、もちろん出力可能です*3

Webアプリにしてみる

今度はWebからClaude Codeを操作できるようにしてみます。

ちなみに既に同様の試みをされている記事もあります。

memo.sugyan.com

zenn.dev

(なお言うまでもなく、本当にこのままアプリ公開したら誰でも遠隔コード実行し放題になるのでダメゼッタイ)

バックエンド: 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() から返される MessageContentBlock がクラス名以外に判定材料を持っていません。 対処として、今回は簡単にクラス名を 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入力の期待

上記の問題に関して、

公式ドキュメント:ストリーミングJSON入力

stdin経由で提供されるメッセージのストリームで、各メッセージはユーザーターンを表します。これにより、claudeバイナリを再起動することなく会話の複数ターンが可能になり、リクエストを処理している間にモデルにガイダンスを提供できます。

というとても気になる記述があります。

が、Python SDKコード、TypeScriptの.d.tsを読んだ感じ対応されていない気配で、今後対応されて解決することを期待しています。

おわり

以上、Claude Code SDKで遊んでみた&Claude Codeの裏側もちょっと見えてきた、という記事でした!

SDKを使う際の参考になったり、SDK使ってなにか作ってみたくなって頂けたら嬉しいです。

We're Hiring

エムスリーでは、使っているツールの裏側にダイブせずにはいられないギークな仲間を募集しています!

エンジニア採用ページはこちら

jobs.m3.com

エンジニア新卒採用サイトもオープンしました!

インターンもこちらから。常時募集しています!

fresh.m3recruit.com

カジュアル面談もお気軽にどうぞ

jobs.m3.com

*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