プログラミング

スマホからサーバーのClaude Codeを使う自分専用Webアプリを作りました

自宅サーバーのClaude Codeをスマホのブラウザから操作するために自作したWebアプリ cliweb の紹介です。Claude Agent SDKのストリーミング入力モードで会話をサーバー側に常駐させる設計、1つのHTTPサーバーに2系統のWebSocketを同居させる振り分け、SDKイベントを正規化したチャットUI、セッションIDを確認してからresumeする復元、画面に入ったコードだけを色付けする遅延ハイライト、ツールの承認、ローカル待ち受けと自分を再起動させないフックまで、実際のコードを抜粋しながらまとめました。

目次

はじめに

自宅サーバーでClaude Codeを動かしている方なら、「パソコンの前にいないとき、これをスマホからも使えたらいいのに」と思ったことはありませんか。
私はずっとこれで悩んでいました。

最初はTermius(スマホのSSHクライアント)でサーバーにSSH接続して使っていました。
でもこれが本当に使いにくかったんですね。
ターミナル経由だとコーディング以外の用途はつらいうえに、日本語入力がまともにできず、メモ帳に書いた文章をコピペして流し込む、という地獄のような運用をしていました。

そこで作ったのが cliweb です。
スマホのホーム画面のアイコンから、自宅サーバーのClaude Codeをチャットアプリとして操作できる、自分専用のWebアプリです。
今ではAndroidの公式Claudeアプリは一切使わなくなりました。
それどころか、パソコンでもこの自作アプリでClaudeと話しています。

この記事は「こんなものを作りました、機能はこれです」で終わらせず、内部でどう動いているのかを、実際のコードを抜粋しながら書きます。
中身はNode.js製のそこそこ込み入ったアプリですが、コードのブロックは雰囲気だけ眺めて読み飛ばしても話が繋がるように、抜粋の直後に必ず日本語で種明かしをします。
「使えればいい」という方も安心して読み進めてください。
逆に自分で似たものを作ってみたい方には、設計判断のところがそのまま参考になるはずです。

「自分も同じものを作ってみたい」「コードを見てみたい」という方は、コメントでお知らせください。
無料でお渡しします。

まず完成形から

細かい説明より先に、出来上がりを見てもらうのが早いと思います。
スマホのホーム画面に追加したアイコンをタップすると、こういう画面が開きます。

チャットUIを開いた直後の画面

メッセージを送ってClaudeが返答したところ

プリセット登録したスラッシュコマンドの一覧

会話履歴の一覧から過去の会話を選ぶ画面

メインのチャットUIは、吹き出し・折りたたみツールカード・差分表示で、ターミナルよりずっと読みやすくなっています。
スラッシュコマンドも使いやすいようにプリセット登録しています。
会話履歴も一覧から簡単に選べるようにしています。

見た目はVS CodeのClaude拡張のチャットに近い、吹き出し+折りたたみのツールカードです。
ここでやれることは、

  • スマホ(やPC)のブラウザから、自宅サーバーのClaude Codeを操作する
  • ターミナルの生画面ではなく、ネイティブなチャットUIで読む(表もコードも崩れない)
  • 日本語入力も画像の添付も、下部の入力バーから普通にできる
  • 接続が切れても会話はサーバー側で生き続け、再接続すれば続きから戻れる
  • 用途別の「会話用プロジェクト」を作って切り替えられる

Claude Codeをそのまま動かしているので、ファイルの読み書きも grep での横断検索も普段どおりです。
しかもClaude Maxのサブスクで動くので、追加のAPI料金はかかりません。

なぜ公式アプリ+MCPではダメだったのか

このアプリの一つ前に、私はObsidianのノートをClaudeから読み書きするためのMCPサーバーを自作していました(MCPは、AIに外部のデータやツールを繋ぐための仕組みです)。
それで一応スマホからもノートは触れたのですが、使い込むうちに不満が出てきました。

公式アプリ+MCP・SSHクライアント・クラウドAIそれぞれで引っかかったところをまとめた表

冷静に考えると、私のObsidianのノートはSyncthingで自宅サーバーにも実ファイルとして同期してあります。
だったらMCPを挟まず、サーバー上のClaude Codeにその実ファイルを直接読みに行かせればいいわけです。
問題は「スマホからどうやってサーバーのClaude Codeを操作するか」だけでした。
それを解いたのがこのアプリ、という位置づけです。

設計の出発点:会話はサーバーに常駐、ブラウザは「使い捨ての画面」

このアプリ全体を貫いている一番の設計判断を、先に説明させてください。
ここが分かると、後の話がすべて腑に落ちます。

スマホは、電車に乗れば圏外になり、アプリを閉じれば接続が切れます。
もし「会話そのもの」がブラウザの接続に結びついていると、接続が切れた瞬間に会話が壊れてしまいます。
そこで、会話の本体はサーバー側で動かし続け、ブラウザはそこに“覗き窓”として繋ぐだけにしました。
会話はサーバーで生きている本体、ブラウザはそれを覗く窓、というたとえが一番近いです。
これなら地下鉄でいったん切れても、地上に出てアプリを開き直せば続きが表示されます。

これをコードでどう実現しているかというと、Claude Agent SDK(Claude Codeをプログラムから動かすための公式の部品)の「ストリーミング入力モード」を使い、プロジェクトごとにClaudeのセッションを1本、サーバー側で走らせ続けています。
その心臓部が、この手作りの非同期ジェネレータです。

_inputGen(gen) {
  const self = this;
  return (async function* () {
    while (!self._ended && self._gen === gen) {
      if (self.queue.length === 0) {
        await new Promise((r) => { self._wake = r; });  // 入力が来るまで眠る
      }
      while (self.queue.length && self._gen === gen) yield self.queue.shift();
    }
  })();
}

日本語にすると、「送るメッセージが無いときは眠って待ち、入力が来たら起こされて、溜まった分を順に吐き出す」という無限ループです。
これをSDKに「ユーザーからの入力ストリーム」として渡しているので、SDK側のセッションはずっと開いたまま、入力が来るのを待ち続けます。
ユーザーがメッセージを送ると _push()queue に積んで _wake()(眠っているジェネレータを起こす関数)を呼ぶ、という仕掛けです。
_gen はセッションの「世代番号」で、/clear したときにこれを進めると、古いループが自分は用済みだと気づいて止まります。

このジェネレータが回り続けているおかげで、ブラウザの接続が切れても会話セッションはサーバーで生きたまま。
ブラウザは後から繋ぎ直して、現在の状態を流し込んでもらうだけで復元できます。

1つのサーバーに、2つのWebSocket

このアプリは、実は2つのUIを併せ持つハイブリッドです。
最初に作った「生ターミナル版」(tmuxの画面をそのまま投影する旧UI)を捨てずに残し、その上に今のチャットUIを乗せました。
WebSocket(ブラウザとサーバーが双方向に通信し続けるための仕組み)も、チャット用と旧ターミナル用の2系統あります。

ここで一手間かけているのが、2つのWebSocketを1つのHTTPサーバーに同居させる部分です。
接続のパス(URL)を見て手で振り分けています。

server.on('upgrade', (req, socket, head) => {
  let pathname = '/';
  try { pathname = new URL(req.url, 'http://localhost').pathname; } catch {}
  if (pathname === '/agent') {       // 新チャットUI(SDKセッション)
    agentWss.handleUpgrade(req, socket, head, (ws) => agentWss.emit('connection', ws, req));
  } else if (pathname === '/ws') {   // 旧ターミナル(tmux/pty)
    wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
  } else {
    socket.destroy();
  }
});

upgrade というのは、普通のHTTP接続をWebSocketに「昇格」させる瞬間のイベントです。
ここで /agent なら新チャット、/ws なら旧ターミナルへと手動でルーティングしています。
なぜ手動かというと、パス付きのWebSocketサーバーを複数ぶら下げると昇格処理が競合して通信フレームが壊れるからで、「両方を noServer で作って、振り分けだけ自前でやる」のが正攻法でした。
この記事では新しいチャットUI側(/agent)を中心に掘り下げます。

ターミナルを「チャット」に変える:イベントの正規化

旧ターミナル版は、端末の生バイトをそのままブラウザに流して、本物そっくりのターミナルとして描いていました。
日本語入力の問題は解決できたのですが、表や長い出力がスマホの細い画面ではどうしても読みにくい。
そこで、生の文字を流すのをやめ、構造化されたイベントに変換してから送ることにしたのが今のチャットUIです。

SDKから流れてくるメッセージを、ブラウザが描きやすい形に整える _onMessage() が、その変換の入口です。
たとえばアシスタントの確定メッセージは、こう分解しています。

case 'assistant': {
  const blocks = (m.message && m.message.content) || [];
  const out = [];
  for (const b of blocks) {
    if (b.type === 'text') out.push({ type: 'text', text: b.text });
    else if (b.type === 'tool_use') out.push({ type: 'tool_use', id: b.id, name: b.name, input: b.input });
    else if (b.type === 'thinking') out.push({ type: 'thinking', text: b.thinking });
  }
  this.broadcast({ type: 'assistant_message', blocks: out, error: m.error || null });
  break;
}

ひとつのメッセージを「テキスト」「ツールの使用(tool_use)」「思考(thinking)」のブロックに振り分けて、ブラウザに送っています。
ブラウザ側はこれを受けて、テキストは吹き出し、ツールはアイコン付きの折りたたみカード、思考は畳んだ <details> ……と描き分けます。
ツールの実行結果(tool_result)や、トークン単位で届く逐次イベント(stream_event)も同じように正規化して送るので、入力中の文字が少しずつ表示され、ファイル編集は差分(diff)として色付きで出る、という今どきのチャット表示になります。

会話を切らさない:常駐とresume復元

「会話はサーバーに常駐」と言っても、サーバー自体を再起動するとSDKのプロセスは消えてしまいます。
そこを埋めるのがセッションの保存と復元(resume)です。

仕組みはこうです。
セッションが始まると発行されるセッションIDを、プロジェクト名と紐づけて小さなファイル(.agent-sessions.json)に保存しておきます。
次に起動するとき、そのIDを使ってClaude Codeに「この会話の続きから再開して」と頼みます。
ただ、ここで一段チェックを挟んでいるのが大事なところです。

let resumeId = null;
if (!this._forceFresh) {
  resumeId = loadStore()[this.proj.name];
  // 保存IDが無く、種付けが安全なdirなら、ディスク上の最新セッションを対象にする
  if (!resumeId && this._safeToSeed() && !this._cleared) resumeId = await this._latestSessionId();
}
if (resumeId) {
  try {
    if (await getSessionInfo(resumeId, { dir: this.proj.dir })) {  // 実在=中身があるか確認
      options.resume = resumeId;
    }
  } catch {}
}

ポイントは getSessionInfo() で「そのIDが本当に中身を持って実在するか」を確認してから resume するところです。
空っぽになったIDや消えたIDをうっかり復元しようとすると失敗するので、その前に存在確認を入れています。
万一それでも壊れた古いIDで復元に失敗したときは、保存を消してまっさらに作り直すフォールバックも用意しました(実際にこの順序でハマってから足した安全網です)。

過去の会話は別物として残っています。
Claude Codeは会話の記録をプロジェクトごとのファイル(JSONL)に残すので、それを読み取って一覧にする「会話履歴」タブを作りました。
一覧のプレビューは、ファイル全体を読まず先頭の16KBだけを読んで最初の発言を取り出すことで軽くしています。
タップすればその会話に切り替えて続きから再開できます。

速く・固まらせないための、フロントの工夫

スマホで快適に使うには、表示の「重さ」との戦いが避けられませんでした。
特にコードのシンタックスハイライト(色付け)が曲者で、長い会話履歴を一気に色付けすると、スマホがしばらく固まります。
そこで、画面に入ったコードブロックだけを遅延処理で色付けするようにしました。

hlObserver = new IntersectionObserver((ents) => {
  for (const e of ents) {
    if (!e.isIntersecting) continue;   // まだ画面に入っていないブロックは後回し
    const b = e.target;
    hlObserver.unobserve(b);
    b.classList.add('hl-done');
    try { hljs.highlightElement(b); } catch {}  // 画面に入った瞬間に色付け
  }
}, { root: transcript, rootMargin: '300px' });

IntersectionObserver は「その要素が画面に入ったか」を監視してくれるブラウザの仕組みで、これを使ってスクロールで見えてきたコードブロックだけを、その都度ハイライトしています。
画面外のぶんは後回しなので、何百行の履歴でも一気に固まりません。
同じ発想で、入力中のトークンを描くのも1フレームに1回へ束ね(requestAnimationFrame)、履歴は25件ずつ小分けに描いています。

もうひとつ、スマホ特有の「いつの間にか切れている」問題にも対処しています。
モバイルではバックグラウンドにいる間にWebSocketが黙って切れ、見かけ上は繋がっているのに送っても届かない、という状態が起きます。
そこで、画面復帰・アプリ切替・回線復帰のたびに接続を必ず新しく張り直すようにし、25秒ごとの生存確認(ping)で死んだ接続を片付けています。

ツールの承認とAskUserQuestion

Claude Codeをそのまま使う以上、「危険なコマンドを実行する前に確認する」挙動も再現しないといけません。
SDKの canUseTool という仕組みを使って、Claudeがツールを使おうとした瞬間にブラウザへ確認カードを出し、ユーザーが答えるまでPromiseでブロックしてClaudeを待たせています。
選択肢は実ターミナルと同じ「許可(今回だけ)」「この種類は以降許可」「拒否」の3つです。
「以降許可」を選んだときは、こう返します。

} else if (decision === 'always') {
  // 「この種類は以降許可」: SDK推奨のsuggestionsをそのまま許可ルール化する
  p.resolve({ behavior: 'allow', updatedInput: p.input, updatedPermissions: p.suggestions });
}

updatedPermissions にSDKが推奨してくる許可ルールをそのまま渡すことで、「次から同じ種類のツールは聞かない」が成立します。

ツール承認カードが表示されているところ

ツール承認カードでは、コマンドや差分を確認して「許可/この種類は以降許可/拒否」を選べます。

承認モードも下部のボタンで循環でき、「通常(毎回確認)/自動承認/プラン(計画だけ立てる)」を切り替えられます。
この「自動承認」は単に編集を全部通すモードではなく、普段ターミナルで使っている賢い分類モードに対応させてあるので、自分の設定ファイルに書いた「自動で許可してよいコマンド」のルールがそのまま効きます。

ちなみに、Claudeが選択肢付きの質問(AskUserQuestion)を投げてくることがありますが、これは承認カードとは別の質問カードとして表示し、選んだ答えを返せるようにしています。

安全に動かす:ローカル待ち受け+「自分を再起動させない」多重防御

自宅サーバーを外から使う以上、安全性は外せません。
基本方針は2つです。

まず、サーバーはインターネットに直接さらさず、ローカルだけで待ち受け、外からのアクセスはTailscale越しに限定しています。
Tailscaleは、自分の端末同士だけを繋ぐプライベートな仮想ネットワークだと思ってください。
グローバルにポートを開けていないので、Tailscaleの中にいない第三者は到達できません。

もうひとつが独特な判断で、サーバーの自動再起動を“あえて”切っています。
普通はコードを直したら自動で再起動させたくなりますが、このアプリは自分がまさにこのアプリを通して会話しているため、サーバーが再起動するとその瞬間に自分の会話接続が切れてしまうのです。
過去に、ファイル監視による自動再起動で会話がぶつ切りになる事故が実際に起きました。
その反省から、プロセス管理ツール(PM2)の設定で監視を切ったうえに、Claude自身が自分を再起動できないようにする歯止めまで入れました。
コマンド実行の前に走る「ガード」がこれです。

const danger = /(^|[\s;&|(])(?:\S*\/)?pm2\s+(?:restart|reload|stop|delete|start|kill|scale)\b[^;&|]*\bcliweb\b/;
if (danger.test(cmd)) {
  // pm2 restart cliweb のような自分を殺すコマンドを拒否する
  process.stdout.write(JSON.stringify({
    hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', /* …理由… */ },
  }));
}

これは、Claude Codeがコマンドを実行する直前に内容を検査する「フック」です。
pm2 restart cliweb のような自分自身を再起動・停止するコマンドだけを正規表現で見つけて拒否します。
ファイル名やコミットメッセージに cliweb が出てくるだけでは誤爆しないよう、「pm2 +操作系サブコマンド+同じ行に cliweb」という形のときだけ弾くようにしてあります。
読み取り系(状態確認など)は素通しです。
コードを直したときの反映は、私が自分の都合のいいタイミングで手動再起動する運用にしています。

どう作ったか(技術構成)

中身を自分で作ってみたい方向けに、土台をまとめておきます。

サーバー・AIの中身・旧ターミナル・フロントの技術構成をまとめた表

主要ファイルはこの3つに集約しています。

cliweb/
├── server.js   HTTP・静的配信・WebSocketの振り分け・旧ターミナル(tmux/pty)
├── agent.js    AgentManager / AgentSession(SDKの常駐セッション・承認・resume)
└── public/
    ├── index.html・chat.js   新チャットUI(このアプリの主役)
    └── terminal.html         旧・生ターミナルUI

データの流れを実際の関数名で追うと、こうです。

ユーザーがメッセージを送ると、ブラウザが /agent のWebSocketで送信 → サーバーの AgentSession.send()_push() でキューに積む → 先ほどの _inputGen() がそれをSDKへ流す → SDKから返ってくるメッセージを _onMessage() が正規化 → broadcast() で繋がっている全ブラウザへ配信 → chat.jsonEvent() が受けて描画、という一本道です。

プロジェクトごとのセッションは AgentManager がメモリ上に束ねていて、プロジェクトを切り替えても、それぞれの会話はサーバー側に常駐したまま保たれます。

なお、ホーム画面に追加してネイティブアプリのように起動できるよう、PWA化(マニフェストとService Worker)もしてあります。
Service Workerは「ネットワーク優先」で、このアプリはサーバーが無いと意味がないのでオフライン動作は狙わず、アプリの“ガワ”を即表示するためだけに使っています。

導入と設定(概念)

導入の流れだけ、概念として説明します。

  • 自宅サーバーにNode.jsとClaude Codeを用意する
  • アプリのサーバーを、ローカル待ち受けで常駐させる(プロセス管理ツールで起動)
  • スマホからTailscale越しにアクセスし、ホーム画面に追加する
  • 接続先のプロジェクト(ディレクトリ)を選んで会話を始める

接続先のプロジェクトは、決め打ちの開発ディレクトリだけでなく、ホーム配下のフォルダや開発ディレクトリ群を自動で集めて一覧に出すようにしてあります。
前提として、自宅サーバーが立っていてTailscaleが入っていることが必要です。

使い方

基本はとてもシンプルです。

  • ホーム画面のアイコンからアプリを開く
  • 上部でプロジェクト(ディレクトリ)を選ぶ
  • 下の入力バーにメッセージを打って送る(日本語OK、画像も添付できる)
  • ツールの確認カードが出たら、許可か拒否を選ぶ
  • 用途を変えたいときは、別のプロジェクトに切り替える

私が一番気に入っているのは、開発と関係ない「会話専用のプロジェクト」をいくつか作る使い方です。
それぞれのフォルダに専用の CLAUDE.md(そのプロジェクトでのClaudeの振る舞いを決める設定ファイル)を置いておくと、雑談・壁打ち用、思考整理用、メモ用……と、性格の違うClaudeを切り替えて呼び出せます。
プロジェクトを行き来しても文脈が混ざらないのは、前述のとおり会話がサーバー側に常駐しているからです。

プロジェクト(ディレクトリ)一覧の画面

プロジェクト一覧では、開発ディレクトリと会話用プロジェクトを切り替えて、用途ごとに使い分けられます。

このアプリが乗っている自宅サーバーの土台

このアプリは、これまで作ってきた自宅サーバーの仕組みの上に乗っています。

  • Tailscale:このアプリに外から安全に繋ぐ土台です。グローバルにポートを開けずに自宅サーバーへ届くのは、この仕組みのおかげです。
  • Syncthing:Obsidianのノートをサーバーにも実ファイルとして同期しているからこそ、MCPを挟まずにClaude Codeで直接読めます。
  • Obsidian MCPとの対比:このアプリは、ある意味でMCPの“続き”であり“答え”でもあります。MCPで感じた限界(不安定さ・横断検索の弱さ)を、実ファイルを直接読む方式で乗り越えたのが、このアプリだからです。

よくあるトラブルとQ&A

実際に使っていて引っかかった点を、症状→原因→対処の形でまとめます。

Q. 会話の途中で急に「中断されました」と出る
A. サーバーが再起動した可能性が高いです。このアプリは自分を通して会話しているため、サーバーの再起動=接続断になります。だから自動再起動はあえて切ってあります。コードを直したときは、自分の都合のいいタイミングで手動再起動してください。

Q. スマホを閉じていたら会話が消えた?
A. 消えていません。会話の本体はサーバー側に常駐しています。アプリを開き直せば再接続され、続きが復元されます。それでも出てこないときは、履歴タブから該当の会話を開いてください。

Q. 表示が古いまま更新されない
A. 画面(フロント)の変更はブラウザのリロードだけで反映されます。サーバー側のコード(server.js / agent.js)を直したときだけ、手動の再起動が必要です。

Q. スマホで途中までしか動かない/送っても返事が来ない
A. バックグラウンド中にWebSocketが切れていることが多いです。一度アプリを表に出し直すと自動で接続を張り直します(画面復帰で再接続する作りです)。

Q. 外から不正にアクセスされない?
A. サーバーはローカルだけで待ち受け、外部アクセスはTailscale越しに限定しています。インターネットにポートを直接開けていないので、Tailscaleのネットワークに入っていない第三者は到達できません。あくまで自分専用のツール、という前提で運用してください。

まとめ

いかがでしたか。
MCPの限界に行き詰まって、最終的に「スマホからサーバーのClaude Codeを直接使う自作Webアプリ」にたどり着いた話を、内部の仕組みまで開けて紹介しました。
要点を振り返ると、

  1. 会話の本体はサーバーに常駐させ(手作りの非同期ジェネレータ)、ブラウザは使い捨ての画面にすることで、切れても会話が壊れない構成にした
  2. 1つのサーバーに新旧2つのWebSocketを同居させ、生ターミナルからSDKイベントを正規化した構造化チャットUIへ作り替えた
  3. セッションIDを保存し、実在を確認してから resume することで、サーバー再起動をまたいで会話を復元できるようにした
  4. ローカル待ち受け+Tailscale、そして「自分を再起動させない」フックの多重防御で、自分専用に安全へ最適化した

結果として、私はスマホでもパソコンでも、公式アプリではなくこのアプリでClaudeと話すようになりました。
用途別の会話用プロジェクトを CLAUDE.md で味付けして使い分けるのが、想像以上に快適です。

「自分も同じものを作ってみたい」「コードを見てみたい」という方は、コメントでお知らせください。
無料でお渡しします。

この記事が誰かの役に立てばうれしいです。