🦴

claude codeをブラウザから操作するためにやったこと

に公開

はじめに

この記事ではClaude codeをブラウザから操作するために作成した内容を公開します。
モチベーションは、プロジェクトとタスクの管理をわけずに通貫でやりたかった。
vibe kanbanとか使えばいいじゃんと思うけど、細かいところとかいじりたくなるから自分で作った方がはやいよね?という感じ。
そのための最初の一歩という位置付け

あーきてくちゃ

  • 3層構造の設計思想
    • Browser (Next.js 16 + React 19)
    • Processor (Node.js WebSocketサーバー)
    • Claude Code CLI

なぜNext.jsのAPI Routesではなく別プロセスにしたのか

主な理由

  1. WebSocketのネイティブサポートがない

    Next.js App RouterはWebSocketを直接サポートしていません。APIRoutesで使えるのはリクエスト/レスポンスモデルかSSE(Server-SentEvents)まで。双方向のリアルタイム通信には別プロセスが必要です。

  2. 長時間実行プロセスとの相性

    Claude Code CLIは一度起動したら数分〜数時間動き続けます。

    • API Routesはリクエストごとに完結するモデル
    • Vercel等のサーバーレス環境ではタイムアウト制限がある(10秒〜60秒)
    • 子プロセスを「保持し続ける」という概念がない
  3. ライフサイクルの分離

    Next.js再起動 → Claude Code CLIセッションは継続したい
    もしAPI Routes内でClaude Code CLIを起動していたら、毎回プロセスが死んでセッションが途切れます。

  4. 最初の設計
    最初はPTYでterm.jsを使って、そのままターミナルをいじろうとしてましたが、term.jsの見た目が全く好みじゃなかったので、途中からヘッドレスモードの実行に切り替えました。

Claude Code ヘッドレスモードでWeb UIを実現する

Claude Codeはターミナルで動作するCLIツールですが、ヘッドレスモード(非対話モード)を活用することで、ブラウザベースのUIから操作することが可能になります。

claude -p \
    --output-format stream-json \
    --include-partial-messages \
    --max-thinking-tokens 2000 \
    --dangerously-skip-permissions \
    --session-id <セッションID> \
    "<プロンプト>"

ストリーミング出力の処理

--output-format
stream-jsonを指定すると、stdoutに以下のようなNDJSON形式で出力されます。

{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_xxx"}}}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"こん"}}}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"にちは"}}}
{"type":"stream_event","event":{"type":"content_block_stop","index":0}}
{"type":"stream_event","event":{"type":"message_stop"}}
{"type":"result","subtype":"success","session_id":"xxx","is_error":false}

これを行単位でパースする実装

let buffer = ''

child.stdout.on('data', (chunk: Buffer) => {
    buffer += chunk.toString()
    const lines = buffer.split('\n')
    buffer = lines.pop() || ''  // 不完全な行は次回に持ち越し

    for (const line of lines) {
      const trimmed = line.trim()
      if (!trimmed) continue

      try {
        const event = JSON.parse(trimmed)
        handleEvent(event)  // イベントを処理
      } catch (err) {
        console.error('JSON parse error:', trimmed)
      }
    }
})

ポイント: データはチャンク単位で到着するため、行の途中で分割される可能性があります。バッファリングして改行で分割し、不完全な行は次のチャンクと結合します。

主要なイベントタイプ

| イベント              | 説明                               
|---------------------|-----------------------------------
| message_start       | アシスタントの応答開始                       
| content_block_start | コンテンツブロック(テキスト、ツール使用など)の開始
| content_block_delta | 部分的なテキスト更新。delta.textに追加テキストが含まれる 
| content_block_stop  | コンテンツブロックの終了                      
| message_stop        | アシスタントの応答完了                       
| result              | セッション全体の終了。成功/失敗の情報を含む            

--include-partial-messagesを指定することでcontent_block_deltaイベントが頻繁に発生し
、文字単位でのリアルタイム表示が可能になります。

セッションの継続

Claude Codeは会話履歴を~/.claude/projects/配下にJONL形式で保存します。--resumeオプションで既存セッションを再開できます。

const args = [
    '-p',
    '--output-format', 'stream-json',
    '--include-partial-messages',
    '--max-thinking-tokens', '2000',
    '--dangerously-skip-permissions',
    '--resume', sessionId,  // --session-idの代わりに--resume
    prompt
]

これにより、過去の会話コンテキストを維持したまま新しいプロンプトを送信できます。
実際のWeb UIでは、このClaude Codeプロセスの出力をWebSocket経由でブラウザに転送します。
このように、ヘッドレスモードを活用することで、Claude Codeの強力なコーディング能力をWebアプリケーションに組み込むことができます。

これらのやりとりをws経由でnextのコンポーネントに渡すとthinkingやtoolなどの出力も、CLIのような出力をブラウザ側でも実現できます。
冒頭で述べたように、vibe kanbanなどはnextから-pオプションを投げるだけなので、この辺が好みを追求した箇所になりました。

今回はここまでです。
このやり方だとClaude Code CLIの便利機能であるrewindが使えないという課題がありました。
次回の記事ではrewindの自前実装について投稿したいと思います。

Discussion