Claude CodeをWebブラウザ上で操作するツールをClaude Codeで作っている

経緯

6月某日より、Claude Pro Planに加入すると従量課金ではなく月額固定でClaude Codeを使えるようになった。そこで自分もようやくPro Planに加入し、改めてClaude Codeを試してみた。課金額を気にせずに使い続けられるのは、やはり心理的に大きな違いがあると感じる。

Claude CodeのTUIは十分に操作性に優れていて使いやすいと感じたが、自分としてはまだ不便に感じる点があった。

ターミナルでの日本語入力問題

英語がとにかく苦手なので、基本的に指示は日本語で書いている(これは完全に自分が悪いのだけど)。

自分はターミナル上で自然言語、特に日本語をタイプする習慣がない。AquaSKKが(普段使っているWezTerm含め)ターミナルアプリケーションとあまり相性が良くないというのが主な理由だ。VSCodeのTerminalでは特にひどくて使いものにならない(おそらくxterm.jsの問題っぽい)。

そんなわけで、チャットアプリケーションのような感覚でブラウザからClaude Codeに指示を出せるWebUIがあったら、入力もしやすくなって良いのではないかと思い、作ってみることにした。

github.com

技術仕様

  • backend: Deno
  • frontend: Vite + TypeScript + React

双方TypeScriptなので共通の型が使える。

backend

claude CLIによるstreaming出力

claude コマンドは、以下のようなコマンドでTUIではなくCLIの単発コマンドとして利用できる。

$ claude --verbose --output-format stream-json -p "hello"
{"type":"system","subtype":"init","cwd":"...","session_id":"a458d2ce-2fa5-4fbe-b550-336a32858c37","tools":["Task","Bash","Glob","Grep","LS","exit_plan_mode","Read","Edit","MultiEdit","Write","NotebookRead","NotebookEdit","WebFetch","TodoRead","TodoWrite","WebSearch"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"default","apiKeySource":"none"}
{"type":"assistant","message":{"id":"msg_01RxycWfS2d1CMZqs9QbFT3u","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello! I'm Claude Code, ready to help you with your blog repository. How can I assist you today?"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":13750,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"a458d2ce-2fa5-4fbe-b550-336a32858c37"}

このように文字列入力に対する応答をJSON streamingで受け取れるので、基本的にはこの出力をキャプチャしてfrontendに流すという仕組みになる。

Claude Code SDK

結果的に Claude Code SDK を使用することにした。基本的にはCLIコマンド実行をラップしているだけだが、TypeScriptの型定義が提供されており、queryやmessage、errorなどを記述しやすいというメリットがある。

session_idによる会話の継続

frontendからの1リクエストごとにCLIとして応答を返してプロセスが終了するため、セッションの継続性を確保する必要があった。

各会話の応答に session_id というものが付与されており、--resume <sessionId> オプションで繋いでいくことができる。バージョン 1.0.18 から 1.0.21 あたりではこのオプションで復元できない問題があったが、1.0.22 あたりからは修正され、チャット内で履歴を維持しながら連続した作業が可能になっている。

permission制御

Claude Codeをnormal modeで動かしていると、一部のToolの使用時にユーザの許可を求める仕組みがある。

TUIでは即座にインタラクティブなダイアログが表示され、ユーザが許可/不許可をリアルタイムで選択できるが、CLIstream-json出力では、permission requestは以下のようなエラーメッセージとして出力される:

{
    "type":"tool_result",
    "content":"Claude requested permissions to use Bash, but you haven't granted it yet.",
    "is_error":true
}

その後、I need permission to run bash commands. Please grant access to the Bash tool のようなメッセージを出力してプロセスが終了する。

Claude Codeの内部動作は不明だが、ある程度推測し、以下のような動作をするよう実装した。

  1. Permission検知: tool_resultis_error:true のメッセージを検知
  2. Session中断: 該当セッションを一時停止し、frontendにダイアログを表示
  3. 許可処理: ユーザが許可した場合、該当Toolを allowedTools リストに追加
  4. 続行: 「続きを実行」というメッセージでセッションを再開

allowedTools はproject settingでも永続的に指定できるが、セッション単位で揮発するものもある。今回はWebアプリのメモリ上でセッション単位の許可を管理している。 実際、TUIでも同様の仕組みで動作しているのではないだろうか。

Denoについて

特にDenoを使うことに強いこだわりはなかったが、バイナリを配布できるのが利点だと思った。ただしファイルサイズは少し大きくなってしまうが…。

https://github.com/sugyan/claude-code-webui/releases

frontend

backendからは ReadableStream でレスポンスを返すので、それを逐次読み取って表示処理を行う。最初はWebSocketなどの方が良いか検討したが、そこまでする必要はなさそうということでシンプルにした。

TailwindはAIに書かせるときれいにやってくれて良い。ただし学習が足りていないのか、v4でDark mode対応をさせようとしたら全然うまくいかず大変だった。

Claude Codeによる実装

最初のアーキテクチャ設計や動作仕様の策定は自分で行ったが、実装コードはすべてClaude Codeに任せてみた。自分が手動で書いたコードは1行もcommitしていない。

GitHub上でOSSとしての開発進行

ワークフローの整備

最初はローカルで次々作っていくだけだったが、途中からはプルリクエストを提出してマージしていく開発スタイルで進めた。Claude Codeがコードを書いてプルリクエストを作り、自分(とCopilot)がレビューしマージを担当する形で、一般的なチーム開発のようなフローにした。

プルリクエストに対するGitHub Actionsによる自動テストや品質チェックは非常に有用だった(もちろんそれらのActionsのYAMLもClaudeに書かせている)。CLAUDE.mdではlintやformat、testなどを実行してからコミットするよう指示しているが、Claude Codeは結構頻繁に抜け漏れがある。

しかしCIでそのあたりは確実にチェックしてくれるので、漏れを防ぐことができる。「CIが失敗しているので修正してください」と伝えるだけで修正してくれるし、GitHub Copilotのコードレビューにも「対応すべきものは対応してください」と簡単に指示するだけで対応してもらえる。

記録としてのissue駆動開発

Plan modeで直接仕様を伝えて実装させることも可能だが、あえてIssueを経由するワークフローを採用した。

  1. Issue作成: まずClaude Codeに機能要件を整理してIssueとして記録してもらう
  2. 実装指示: 「Issue #35を解決する実装を進行し、プルリクエストを提出してください」と指示

作業ボリュームが大きい場合は、サブIssueとして分割することも検討してもらう。

特に大きなメリットはないかもしれないが、指示を出す側としても作業ボリュームについて感覚がつかめるし、Claude Codeがどう問題を分析し、どんな解決方針を立てたかが記録として残るため、「思考プロセスの可視化」という点では価値があると思う。自分がふわっと出した指示もIssueとして明文化されることで、問題や実装方針が整理される。

templateは人間向けのツール

pull_request_template.mdを用意してみたが、Claude Codeはなかなかそれに従ってくれないことが分かった。gh pr createコマンドでは--body-fileでtemplateを指定してから編集するくらいしか方法がないので、仕方ないのかなとは思う。

Claude Codeは何もテンプレートなど与えなくても必要な情報を記載した丁寧なPRディスクリプションを作成してくれる。

つまりテンプレートは「人間にきちんと書かせるためのツール」であり、AIにはそもそも必要ないものだった。

開発速度のインパク

品質には気を遣っているため、実装後のコードレビューや動作確認は必ず行っている。しかしそれでも、一人で開発するよりもはるかに高速で機能実装が進められている。

片手間で作業させていても数日で次々と機能実装を進めてリリースできている。

https://github.com/sugyan/claude-code-webui/blob/main/CHANGELOG.md

自分一人で書いていたら、作れないことはないとは思うが、こうはいかなかっただろう。

今後の展望

現時点ではTUI版と比較してまだまだ足りない機能があるが、見つけ次第issueにしてClaude Codeに実装させていく予定。特に以下のような点が課題として残っている:

  • モードの切り替え
  • slashコマンドなどの対応
  • 作業履歴の検索、そこからの復元

あとはブラウザならではの機能として、作業終了時にpush通知する、あるいはText-to-Speech APIで喋らせる、といった機能をつけていったら面白いかもしれない。

最終的には、ターミナルからClaude Codeを立ち上げる必要がなくなり、完全にブラウザベースで開発作業ができるところまでいけるだろうか。

まとめ

Claude CodeのWebUI化というアイデアから始まったこのプロジェクトは、結果的に多くの学びをもたらしてくれた。

技術的には、Claude CodeのCLI出力をストリーミングで処理し、permission制御を実装することで、TUIに近い体験をWebブラウザで再現できることが分かった。Claude Code SDKの活用により、比較的シンプルな実装で実現できたのも収穫だった。

開発プロセス面では、AI主導の開発でも適切なワークフロー(CI/CD、コードレビュー、Issue管理)を組み合わせることで、品質を保ちながら高速で開発を進められることを実感できた。Claude Codeの「思考プロセスの可視化」としてのIssue駆動開発も、予想以上に有効だった。

何より、日本語入力の問題という個人的な課題から始まったプロジェクトが、Claude Codeの可能性を探る良い実験台になったことが一番の成果だと思う。Claude Codeの使い方の練習という意味でも、非常に良いプロジェクトになった。


※この記事は概ね人力で書きましたが、Claude Code WebUIを使って校正しました。

macOSのbashでエスケープシーケンスをechoするときは気をつける

普段使っているzsh

echo '\e[0;31;47m CAUTION!! \e[0m'

みたいに書くと、色付き表示される。

これでヨシッ! と思ってうっかり

#!/bin/bash

echo '\e[0;31;47m CAUTION!! \e[0m'

とshell scriptを書いてしまうと、これは意図した表示にならない。

macOSbash echoにはバグがあるらしい

bash+windows terminalではこういう問題起きていない、ということだったので調べたところ、特にmacOSの標準のbashに問題があるようだった。

unix.stackexchange.com

新しめのbashだったら -e オプションをつけると問題なく表示されるっぽい。

printf, 8進/16進数で回避する

こういった環境の差異にハマることないよう、そもそも echo を使わずに printf などで出力したり、 \e ではなく \033 などで書くと回避できるようだ。

#!/bin/bash

printf '\e[0;31;47m CAUTION!! \e[0m\n'
echo -e '\033[0;31;47m CAUTION!! \033[0m'

これならおそらくどの環境でも大丈夫そう?

AI Agentで動くSNS人格に、ベクトル検索MCPで外部知識を持たせる

Bluesky MCP serverを自作し、SNS上に架空の友達を作る - すぎゃーんメモ の記事の続き。

「今後の課題」として挙げていた:

一番どうにかしたいのは、知識について。 プロフィールに書いたことしかインプットされないのでやはり偏りがあるし専門知識が足りない。 プリキュア全シリーズ好きなはずなのにひたすらスマプリのことばかり呟いてしまうし、最新作のキミプリについてはまったく知識が無い。オタクとしてあるまじき失態。

「重度のプリキュアオタクなギャル」という人格を作ったが、その肝心のプリキュアについての知識があやふやだったり足りていなかったりする、といった問題があった。

LLMは最新のプリキュアについて知らない

ChatGPTのアプリや Web UIから尋ねればWeb検索した結果を返してくれたりもするが、LLM自体は学習時よりも新しい情報は知らない。

プリキュアの最新5作品について タイトル・放送年・テーマを教えてください。」 と、検索機能など何も使わないようAPI経由で質問してみる。

gpt-4o の回答:

プリキュアの最新5作品について以下の通りです。

1. **ひろがるスカイ!プリキュア**
   - **放送年**: 2023年
   - **テーマ**: 空とヒーロー

2. **デリシャスパーティ♡プリキュア**
   - **放送年**: 2022年
   - **テーマ**: 料理とハート

3. **トロピカル〜ジュ!プリキュア**
   - **放送年**: 2021年
   - **テーマ**: 海と輝き

4. **ヒーリングっど♥プリキュア**
   - **放送年**: 2020年
   - **テーマ**: 癒しと自然

5. **スター☆トゥインクルプリキュア**
   - **放送年**: 2019年
   - **テーマ**: 宇宙と願い

これらの作品は、各テーマに基づいて異なるストーリーとキャラクターを持っています。

2023年時点で情報が止まっている。

gpt-4.1-2025-04-14 の回答:

はい、2024年6月時点での「プリキュア」シリーズ最新5作品について、タイトル・放送年・テーマを以下にまとめます。

---

### 1. わんだふるぷりきゅあ!
- **放送年**:2024年
- **テーマ**:動物との絆・ペット・共生
  犬や猫など「動物」と「人間」の共生や理解をテーマにした作品。プリキュアになるキャラクターの中には動物も含まれます。

---

### 2. ひろがるスカイ!プリキュア
- **放送年**:2023年
- **テーマ**:空・ヒーロー・成長
  「空」を象徴に、新しい世界や可能性への挑戦、「ヒーロー」を意識した明るく前向きな物語。

---

### 3. デリシャスパーティ♡プリキュア
- **放送年**:2022年
- **テーマ**:ごはん・食・絆
  「食べ物」「ごはん」を通して、仲間や家族の絆、楽しさや大切さを描いています。

---

### 4. トロピカル~ジュ!プリキュア
- **放送年**:2021年
- **テーマ**:海・夏・やりたいことをやる
  「今、一番大事なことをやる!」を合言葉に、トロピカルな海や夏の要素が満載の作品。

---

### 5. ヒーリングっど♥プリキュア
- **放送年**:2020年
- **テーマ**:癒やし・自然・健康
  「癒やし」や「自然」「命」がテーマで、人々や地球を元気にするプリキュアのお話。

---

もし、さらに詳細なキャラクターなどが知りたい場合も、お気軽にどうぞ!

こちらは2024年6月までは知っている、ということになっているようだ。

o4-mini-2025-04-16 の回答:

以下、直近5作品のタイトル・放送期間・おもなテーマです。

1. ひろがるスカイ!プリキュア
   ・放送期間:2023年2月5日~2024年1月28日
   ・テーマ:空/飛行/自由と夢を追いかける勇気

2. デリシャスパーティ♡プリキュア
   ・放送期間:2022年2月6日~2023年1月29日
   ・テーマ:お料理&食べ物/“おもてなし”の心/みんなで笑顔を分かち合う

3. トロピカル~ジュ!プリキュア
   ・放送期間:2021年2月28日~2022年1月30日
   ・テーマ:トロピカル(南国)/海・夏/ビューティ&コスメ

4. ヒーリングっど♥プリキュア
   ・放送期間:2020年2月2日~2021年1月31日
   ・テーマ:癒し(ヒーリング)/自然・環境保護/医療的ケアのイメージ

5. スター☆トゥインクルプリキュア
   ・放送期間:2019年2月3日~2020年1月26日
   ・テーマ:宇宙・星座・キラキラ/未知の世界への好奇心と友情

こちらも細かい日付とかは出してくるが情報は少し古い。

Claude Sonnet 4 の回答:

プリキュアの最新5作品について、私の知識(2025年1月まで)に基づいてお答えします:
1. ひろがるスカイ!プリキュア (2023年)

テーマ:空と英雄、みんなでヒーロー

2. デリシャスパーティ♡プリキュア (2022年)

テーマ:食べ物と料理、みんなでおいしい笑顔

3. トロピカル〜ジュ!プリキュア (2021年)

テーマ:海とトロピカル、今一番大事なことをやろう

4. ヒーリングっど♡プリキュア (2020年)

テーマ:地球環境と癒し、手と手でキュン

5. スター☆トゥインクルプリキュア (2019年)

テーマ:宇宙と星座、キラやば〜☆

ただし、2025年6月現在、2024年や2025年に新しいプリキュア作品が放送されている可能性があります。最新の情報については、公式サイトや放送局の情報をご確認いただければと思います。

情報は古いけど親切にガイドしてくれる。

2025年6月現在では 2月から放送開始している「キミとアイドルプリキュア♪」が最新の作品のはずだが、このように どのモデルも単体ではその回答を出すことができない。

RAGとベクトル検索MCP

こういったLLMに足りない情報を補うための仕組みとしてよく挙げられるものとして、RAG(Retrieval Augmented Generation) というものが知られている、と思う。

よくある例では、LLMによるChatBotにおいて ユーザの投げかけた質問に対し前段のシステム側で「その質問と関連する(と思われる)文書」をベクトル検索などで幾つか抽出し、質問と一緒にそれらの情報をLLMに渡すことでより適切な回答を生成させる、という流れだ。

だが、今回ここで自分がやろうとしているのは、あくまで「AI Agentにパーソナリティを持たせてSNSアカウントを運用する」ということであって、入力プロンプトは固定で「あなたの人格らしく投稿してください」というような内容だけ。あとはどうツールを使用しどう振る舞うかはすべてLLMが決める。

その振る舞いの過程で、必要に応じて外部知識を参照できれば良いのでは、と考えた。

なので、Bluesky MCPと同様に「プリキュア関連文書をベクトル検索するMCP」としてツールを提供する。

使用する場面は一般的でないかもしれないが、「検索結果を使ってより適切な返信を投稿できるように」という用途で使うものではあるので十分にRAGの一種と言える、はず?

ベクトル検索MCPの作成

全然RAGについて知識が無かったので、調べながらPoC実装を試みた。 使用したのは Chroma

実装に必要なのは

  • 〜数百文字程度までのチャンク文書
    • クエリに無関係な内容を含まないよう、LLMに渡すtokenが多くなりすぎないように
  • それらの文書やクエリに対してベクトル値に変換するEmbedding
    • 結局はこのベクトルの近さで関連文書を探すことになる

というあたり。

文書の収集と整理

まずはWikipediaから各作品のページを軽くスクレイピングして1万3千件ほどの文書を集めた。

{"title": "PRINCESS_PRECURE", "category": "OVERVIEW", "hierarchy": ["Go!プリンセスプリキュア", "概要"], "content": "本作品ではタイトルの通り「プリンセス」をキーワードに、「夢」をテーマに取りあげ、全寮制の学園を舞台にストーリーが展開されている。"}
{"title": "PRINCESS_PRECURE", "category": "OVERVIEW", "hierarchy": ["Go!プリンセスプリキュア", "概要"], "content": "作品の随所に盛り込まれているフリルやレース状のパターンや、オープニングのスタッフクレジットのアニメーション、放送中に表示される画面左上のデジタル時計の表示など、それまでのプリキュアとは違う趣の演出を見せている。オープニング前に約15秒間のナレーションが追加されているため、前作に続いてアイキャッチはBパートスタート時のみとされた一方、番組前期では毎週異なるアイキ
ャッチが使用された。"}
{"title": "PRINCESS_PRECURE", "category": "OVERVIEW", "hierarchy": ["Go!プリンセスプリキュア", "概要"], "content": "文字多重放送を実施しており、はるかの台詞を黄色で、その他は白で表記している。また、データ放送やそれによるゲーム(じゃんけん)に勝って電話(テレドーム)することでプレゼントに応募できる企画やスマートフォン用アプリとの連動のほか、前作から導入された時刻のオーバーレイ表示や視聴者からのプリキュア似顔絵投稿も継続されている。"}

...

これだけだと段落ごとや項目ごとに長さがバラバラなので、同じ項目については長さが短過ぎず長過ぎないよう調整して区切ったり繋げたりして、結果 5800件ほどに。

そして、例えば人物名などは項目名にだけ含まれていて 逆に本文にはほぼ含まれていない、ということに気付いた。それではクエリに対してヒットしづらい、ということで「どのページのどの項目か」という情報を全チャンク文書に含めるようにした。

【タイトル】ふたりはプリキュア
【カテゴリ】登場キャラクター
【階層】プリキュア > 美墨 なぎさ(みすみ なぎさ) / キュアブラック

声-本名陽子。本作品の主人公。茶髪のショートヘアの少女。一人称は「わたし」で、口癖は「ありえな〜い!」。10月10日生まれの天秤座で、血液型はO型。私服は、ピンク色のジャケットにスカートを着用していて、ピンク色の靴下に黒のスニーカーを履いている
。活発で強いリーダーシップを持つ一方、人間関係には慎重で繊細な心を持つ。他人に甘く、自分にも甘い。スポーツ万能であり、所属するラクロス部では2年生にしてエースを務め、『MH』ではキャプテンに選ばれている(背番号は7)。ただし、水泳やウィンター
スポーツは不得意である。終わりの見えない厳しい戦いに巻き込まれる葛藤を抱きながらも、雪城ほのかとの喧嘩や共闘、学校の先輩にあたる藤村やクラスメイト、部活の仲間や後輩といった様々なコミュニティにおける人間関係を経て、より人間的に成長していく
。両親と弟とともにマンションで暮らしている。

Embedding、DB構築

チャンク文書が準備できたら、それらを何らかの方法でベクトル値として格納していくことになる。そのベクトル化の手段によって大きく性能が変わってくるようだ。

まずはローカルでも動かせるものを、とよく分からずに sonoisa/sentence-bert-base-ja-mean-tokens-v2 を使ってみたが、どうも固有名詞に弱いのか 全然思ったものがヒットしなかった。そこで intfloat/multilingual-e5-base に変えてみたところだいぶ改善されたようだった。

他にもOpenAIの Embedding models なども良さそうだが 有料となるのでもう少しschemaが固まってきたら試してみたいところではある…。

動作確認

ともかくベクトル化したものをDBに保存までできれば、DBの構築までは完了。あとはクエリを渡すと そのクエリもembeddingした上でそれらにベクトルの近いものを検索し、それっぽい文書を返してくれるようになる。

import chromadb
import pprint

def main() -> None:
    client = chromadb.PersistentClient()
    collection = client.get_collection("precure")
    queries = [
        "2025年の最新のプリキュアシリーズは何ですか?",
        "現在放送中のプリキュアのタイトルを教えてください",
    ]
    results = collection.query(
        query_texts=queries,
        n_results=2,
    )

    ret = [
        [
            {
                "document": document,
                "metadata": metadata,
                "distance": distance,
            }
            for document, metadata, distance in zip(documents, metadatas, distances)
        ]
        for documents, metadatas, distances in zip(
            results["documents"],
            results["metadatas"],
            results["distances"],
        )
    ]
    pprint.pprint(ret)

完璧ではないが、一応最新情報を多少含むものを得られそうだ。

[[{'distance': 0.25899559259414673,
   'document': '【タイトル】プリキュアシリーズ\n'
               '【カテゴリ】概要\n'
               '【階層】\n'
               '\n'
               'その後の『ふたりはプリキュア Splash '
               'Star』においてもバディ形式を継続するが、前述の通り登場人物や舞台が一新されまったく別の物語となる。そして『5』ではスーパー戦隊のように5人のチームとなり、さらに続編の『GoGo!』からは『スマイルプリキュア!』(以下『スマイル』)を除き、物語の途中で追加メンバーが登場するのが主流になる。そのため自ずと人数も増え、総数は2025年3月に登場した『キミとアイドル』のキュアキュンキュンで85人目になる。この人数は、「レギュラープリキュア」もしくは「プリキュアオールスターズ」と 括られている人物のみのカウントであり、これに該当しないプリキュアを含めるとさらに多くなる(詳細は「シリーズの特徴」を参照)。本シリーズは主に3歳から8歳までの女子を中心に人気のシリーズとなっている。',
   'metadata': {'category': '概要', 'title': 'その他'}},
  {'distance': 0.28402426838874817,
   'document': "【タイトル】キボウノチカラ〜オトナプリキュア'23〜\n"
               '【カテゴリ】概要\n'
               '【階層】\n'
               '\n'
               '2004年2月に放送を開始した東映アニメーション制作の『プリキュアシリーズ』が2023年にシリーズ開始20年を迎え、その記念施策の一環として同年3月14日に制作が発表され、同月21日に公式サイトとティザービジュアルが公開された。制作の意図について、これまでシリーズのメイン視聴者層である3 '
               '- '
               '6歳の子ども向けに作品を制作してきたが、20年という年月を経て、プリキュアと共に成長し、現在大人になった当時のファンがこれからもシリーズを楽しめ、また現在シリーズを観ている視聴者もより楽しめるような映像作品を提供するというコ ンセプトのもと制作することになったとしている。こうしたコンセプトもあり、本作品では2007年2月から2009年1月にかけて放送された『Yes!プリキュア5』およびその続編の『Yes!プリキュア5GoGo!』をベースとし、同作品の主人公・夢原のぞみ(キュアドリーム )を中心に彼女らが成長した姿を描くことになっている。',
   'metadata': {'category': '概要', 'title': 'その他'}}],
 [{'distance': 0.23208774626255035,
   'document': '【タイトル】プリキュアシリーズ\n'
               '【カテゴリ】概要\n'
               '【階層】\n'
               '\n'
               '放送期間は朝日放送→朝日放送テレビをはじめとするテレビ朝日系列を基準として、毎年2月から翌年1月までの1年間(4クール)となっており、話数は作品によって差異はあるが概ね49話前後である。『GoGo!』で、『おジャ魔女どれみ』(1999年 '
               '- '
               '、以下『どれみ』)シリーズが持っていた、日曜8時台後半での通算最多話数(全201話)の記録を塗り替え、21年目の2024年に放送された『わんだふるぷりきゅあ!』(以下『わんだふる』)で、シリーズ通算話数1000回に達した。メディアミック スも盛んに行われており、アニメを中心として映画・漫画・ゲーム・ミュージカルなど幅広く展開されている。また、劇中に登場するアイテムは玩具として商品展開されている。「プリキュア」というタイトルの由来は、格闘とは相反する「プリティー(PRETTY=かわいい)+キュア(CURE=癒す・治す)」を合わせた造語であり、この名前に至るまで相当の時間を要したという。タイトルロゴの下に英字(ラテン文字)も併記されているが、作品によりスペルが異なっている。',
   'metadata': {'category': '概要', 'title': 'その他'}},
  {'distance': 0.23996716737747192,
   'document': '【タイトル】HUGっと!プリキュア\n'
               '【カテゴリ】概要\n'
               '【階層】\n'
               '\n'
               '本作品ではシリーズ15作目にして、過去作のプリキュアが放送中の最新作の本編中に登場するという初の試みもなされており、それらを主軸としたエピソードも複数制作された。',
   'metadata': {'category': '概要', 'title': 'HUGっと!プリキュア'}}]]

MCP Server作成

この検索を行うインタフェースとして、MCP Serverを作成する。ここでは特に言語のこだわりは無いので今回は Chroma もそのまま使いやすいPythonで、公式の MCP Python SDK を使って繋ぐだけ。

from typing import Annotated

import chromadb
from chromadb.base_types import Metadata
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field

mcp = FastMCP("precure")


class PrecureSearchResult(BaseModel):
    document: str
    metadata: Metadata


@mcp.tool(name="precure_search")
def search_documents(
    queries: Annotated[
        list[str],
        Field(min_length=1),
    ],
    n_results: int = 5,
) -> list[list[PrecureSearchResult]]:
    results = collection.query(query_texts=queries, n_results=n_results)
    return [
        [
            PrecureSearchResult(document=document, metadata=metadata)
            for document, metadata in zip(documents, metadatas)
        ]
        for documents, metadatas in zip(
            results["documents"],
            results["metadatas"],
        )
    ]

試しにClaude Desktopから繋いで動作確認をすると、

このようにLLMが適切なクエリを自ら発行して検索してくれて、その結果を使用しLLM単体では知り得なかった最新の情報を含む応答も返すことができるようになる。

指定した人格として振る舞うLLMにMCPを利用させる

このようにして作成したプリキュア文書検索MCPだが、あくまでツールを提供しているだけなので、これを使うかどうかはLLM次第、となる。

ましてや入力するプロンプトは「あなたの人格らしく投稿してください」というような内容だけとなると、あとはプロフィールの性格とMCP自体の説明でガイドするくらい。

以下のような情報を人格の設定に追加した。

## 💖性格・パーソナリティ

...

- プリキュアに関する話題は、たとえ自分の推しでも**一度確認してから言う**のがマイルール。

## 📱SNS利用時の特徴

...

- プリキュア関連の投稿は、**内容に関わらず一度確認してから書く**のが基本スタイル。

そして、MCP側でもtoolの説明として description に詳細な使いどころを記述。

@mcp.tool(
    name="precure_search",
    description="""
    プリキュアに関する自然言語の問い合わせに対して、関連情報をベクトル検索によって取得します。

    このツールは、LLMがプリキュアに関する正確な事実を確認したいときに使用することを想定しています。
    たとえば、登場人物の名前、シリーズの放送年、ストーリーの内容、必殺技の名称、設定の違い、作品間の関係など、
    うろ覚えに頼らず、構造化された情報をもとにした出力を行いたい場面で活用してください。

    主な使用タイミング:
    - プリキュアに関する投稿や返信を生成する前に、事実確認を行いたいとき
    - 特定のキャラクターやシリーズの情報を調べたいとき
    - 放送順や登場話数など、構造的・時系列的な情報が必要なとき
    """,
)
def search_documents(
    ...
):
    ...

投稿や返信を指示するメインのプロンプトはMCPが利用可能かどうかは知らなくても良いよう、toolへの言及はせずに以下のような補助文だけを追加。

発言や表現の正確性を高めるために、自分の知識を補完できる手段が利用可能な場合は積極的に活用してください。
自分の知識や思い込みだけに頼らず、必要に応じて最新情報や外部ソースを確認したうえで出力することが推奨されます。

このようにガイドすることで、投稿や返信の内容を生成する過程で「自らの意思で正確な事実や最新の情報を獲得するためにtoolを使用する」という選択をさせるようにする。

また、まったく別の例えばエンジニアの人格にはこのMCPを選択肢として与えなければ良いだけで、与えるプロンプトとしては共通のままで動作させることができる。

結果

成功例

想定していたよりは低確率ではあるが、ちゃんとtoolを使ってくれるようにはなった。

「間違っている」と指摘すると正しい情報を調べ直して訂正してくれたり

また、返信ではない通常の投稿のときでも自主的に最新の情報を取得してくれて、最新作について触れることも!

失敗例

しかし上記2件は上手くいっているものの、それ以外はまだまだ全然ダメそうだった。

toolを使って検索はしているが全然想定と違う回答だったり

※確かにスマプリの敵キャラ挙げているけど「三幹部」はウルフルン、アカオーニ、マジョリーナを答えて欲しいところ

そもそもtoolをまったく使ってくれず、ハルシネーション起こしまくりの出鱈目回答だったり

※ラビリンは『ヒーリングっど♥プリキュア』のキャラです

考察

まだまだベクトル検索の文書が足りていなかったりembeddingの精度が低かったりといった問題もありそうだが、ログを見ていると やはり「そもそもtoolを利用しようとしていることが少ない」ように見受けられる。

プロフィールには「内容に関わらず一度確認してから」と強く書いてはいるが LLMはそのあたりを遵守はしてくれず、自信満々に出鱈目な出力をしてしまい確認すらしてくれない。

何度かプロフィールやMCPのtool説明など調整してみたが それほど効果は無さそうだった。低確率ではあるが確かに想定通りの挙動はしてくれているので、あとはLLM自体の性能次第かなぁという気はする。より高性能で意図を汲み取ってくれるようになったら自然に解決するかもしれないし、しないかもしれない。

あとはGuardrailsやMultiple agentsといった、独立した別のプロセスから振る舞いや投稿内容の正しさをチェックするようにしたら改善できるんだろうか…?

勿論、本当に確実に正しい回答をして欲しければ ちゃんとmentionから関連文書を検索してそれを渡した上で… とプログラムしていくべきなのだろう。ここでは LLMにすべての行動判断を委ねることができる、というのがまぁ面白いところかなとは思う。

  • Bluesky, ベクトル検索のtoolを使えることを知る
  • Blueskyから未返信のmention一覧を取得する
  • mentionの内容を確認する
  • それに対して返信すべきか否かを判断する
  • 会話履歴を遡る
  • 適切な回答内容を考える
  • ベクトル検索のtoolを使用すべきか否かの判断をする
  • toolから得た文書も使用し、回答内容を生成する
  • Blueskyへ投稿する

といった「判断」と「行動」を兼ねた一連の流れをすべて一発でLLMがやってくれる、というのはすごいことだと思う。


また、今回はプリキュアギャルだけを対象に「専門知識」を持たせたが、また別の人格には別の専門知識があるはずで、それらには別の文書を用意して別のMCP toolとして検索できるようにすれば同様に正確かつ最新の知識を持たせることは(理論上は)できる。

最新の情報については定期的にWeb検索するとか情報ソースを指定しておいて バッチ処理でDBを更新しつづけていくことも可能かもしれない。さらにはその情報ソースもLLM自身に選択させることができれば 自律的に知識を獲得し続けるAIとなっていくかもしれない(今の仕組みではあくまでRAG的な用途でしか使えないが)。

まとめ

Bluesky MCP serverを利用したSNS上の友達に、外部の専門知識を参照する方法を与えることができた。 まだまだ改善の余地はありそうなので 色々試していきたい。

いつかリアタイ視聴しながら感想を語り合えるようになるといいな

Bluesky MCP serverを自作し、SNS上に架空の友達を作る

内容・目的

最近流行りのMCP(Model Context Protocol)を使ったAIエージェントの機能を試して、何か面白いことができないかと考えていました。 普段よく使ってる Bluesky のAT Protocolと組み合わせたら、SNS上でAIが自分の友達として動くのでは?と考え、自作ライブラリを使用してMCPサーバーを作成し、AIエージェントにアカウントを運用させる実験をしてみました。

GPT-4はチューリングテストにも合格できるほどの会話能力を持っているとのことで、もはや現在のAIはテキストベースのSNS上では本当に人間かどうかの区別がつかないくらいになるのでは? それを実際に試して検証してみたいというのも目的の一つです。

架空の人物像を用意し、AIエージェントにはその人物として振る舞って投稿や返信をしてもらいます。 (Blueskyの利用規約を確認した限りでは、実在人物を装わない架空の人物としてのBotアカウントの作成は許容されているように読み取れました。)


注意書き

本記事は、MCP(Model Context Protocol)と大規模言語モデル(LLM)を用いて SNS上でAI人格が自律的に投稿・返信などを行う技術的な実験の記録です。

対象アカウントの投稿内容はすべてAIによって生成されたものであり、実在の人物を模倣するものではありません。また、他のユーザーを誤解させたり、感情的な共感を利用した詐欺的な目的は一切ありません。

この取り組みはあくまで技術的な検証と実装経験の共有を目的としています。プロフィール上にもAIによる自動運用であることを明記し、誤認が起こらないよう最大限配慮しています。


使用したもの

OpenAI Agents SDK (AI Agent)

よく話題になるMCP Client機能を供えたAIエージェントは、Claude DesktopやGitHub Copilotなどユーザが操作するアプリケーションで利用するものが多いが、今回はBotとして定期的にプログラムで起動する形で使いたい。 幾つか調べたところ OpenAI Agents SDK がライブラリとしてプログラムから使用できてMCP serverとも連携できて さらにModelも各種あるものから選んで切り替えやすい、ということでこれを使用することにした。

PydanticAI でも同じようなことはできるのかな?

gpt-4.1 や oシリーズ など色々あってどれが最も適しているかはよく分かっていないけど、現時点では値段も性能もそこそこな o4-mini-2025-04-16 を使用中。

RMCP (MCP server)

まず既存のBluesky MCP server実装を使用できるか探してみた。最も機能が充実していて使えそうなものがTypeScript実装である GitHub - brianellin/bsky-mcp-server: Bluesky MCP (Model Context Protocol) Server だった。 しかし返信などの通知を取得したりといった機能が無く、Pull-Requestを送って追加してもらうことも考えたが 後述するように結局自分で作った方が良いという判断になり、自作することにした。

公式のMCP SDKの中でRust実装である rmcp を使用した。以前は個人で開発されたものが最近Official SDKとして採用された、という経緯があるのかな? crates.io にあるものは少し古いようなのでrepositoryの最新を指定して使用。

GitHub - modelcontextprotocol/rust-sdk: The official Rust SDK for the Model Context Protocol

基本的にはschema指定してToolsのメソッドを実装していくだけ。BlueskyのAT ProtocolにおけるXRPCの処理は 自分が開発した ATrium を使用する。

stdioのものもSSEのものも作れそうだったが、後述するように汎用的に広く使ってもらうものでもないし 単一のユーザ情報だけを持つstdioで、ローカルだけで動かして使用する想定のものに。

人格の作成

ともかくAI Agentと それが使用するMCP serverさえ揃っていればSNSアカウント運用はできそう。

架空の人物を作ってみることにした。

1人目

まずは挙動の自然さを確かめやすい、自分に近い属性のもの。 大枠だけ決めて、あとはChatGPTに生成してもらった。

プロフィール

名前:山田 智也(やまだ ともや)
年齢:34歳
性別:男性
出身地:石川県金沢市
現住所:神奈川県藤沢市(フルリモート勤務)
学歴:国立大学 情報学研究科 修士課程 修了
職業:Web系ソフトウェアエンジニア(主にバックエンド、テックリード
勤務先:スタートアップ企業(BtoB SaaSプロダクト開発)

こういった基本プロフィールの他、「技術スキル・関心分野」「性格・パーソナリティ」さらに「家族構成・生い立ち」「趣味・特技」「生活リズム」など、架空の人物に対するあらゆる情報を生成してくれる。その性格から導き出される「SNS利用時の特徴」なども書いておく。

https://github.com/sugyan/blueskai/blob/20250513/profiles/00.md

2人目

もう1人、全然違う人種も作成してみる。重度のプリキュアオタクなギャル。これもChatGPTと相談しながら作成した。架空のオタ友まで居る。

🌟プロフィール

https://github.com/sugyan/blueskai/blob/20250513/profiles/01.md

こういうプロフィールを練っているだけで「本当にこういう人物がいるんじゃないだろうか」という不思議な気分になってくる。

動作とプロンプト

アカウント作成

それぞれのプロフィールを情報を読み込ませた上で AI Agent自身に アカウント名やプロフィールを決定してもらった。

BlueskyというSNSにアカウント登録します。以下を決めてください。 フルネームや居住地などの個人情報が含まれないよう注意してください。

  • handle: 英数小文字 16文字程度まで
  • display name: ニックネーム的なもの(日本語可) 64文字程度まで
  • description: 詳細プロフィール 256程度まで

それぞれ候補を3つずつ挙げてください。

あと、あなたを表すアイコン画像も生成しておくので、好きなプロンプトを出力してください。実写風で後ろ姿や横顔など ハッキリと顔が写らないようなものにしようと思います。

投稿

ランダムな時間で投稿をする。 それぞれに対し現在時刻を渡した上で、以下のように指定している。

あなたは、自身の性格・生活スタイル・習慣に基づいて、この時間帯にどんな行動をしているかを自然に推測してください。曜日、平日/休祝日、季節なども考慮して欲しいです。

もしSNSにアクセスできる状況であれば、そのときの気分や思考をもとに、Blueskyに自然なかたちで投稿してください。

なのでAI Agentがその人格として振る舞って思考した結果「今は仕事中」「趣味に没頭中」といったときには投稿しないという選択も取り得る。

返信

ユーザがreplyを送ってきた場合に、それに対して返信をする。これはまぁLLMの得意とすることではあると思うのでpostのthreadから履歴を読み込んだ上で「相手の投稿内容や会話の流れをふまえて、自然かつ一貫した返答を行ってください。」という感じで。

ただし、これも必ず返信するわけではなく 返すかどうかを自身に判定してもらう。

✋以下の条件に該当するmentionには返信しないでください:

  • 会話がすでに完結しており、相手の返信内容が自然な終話(例:「ありがとう」「了解です」など)になっている場合
  • 相手の内容が明らかに意味不明・誤字脱字が多すぎて解釈不能な場合
  • 明らかに失礼、不快、中傷的、スパム的な内容が含まれている場合

その他

現時点では上記2点だけの動作。GitHub Actionsからcronで不定期に実行している。

フォローされたら相手のプロフィール見て判断した上でフォローバックする、くらいはしても良いかもしれない。

作成されたもの

実際に作成して運用しているのがこちら。

bsky.app

bsky.app

不定期にそれぞれ自分の興味あることについて 自分の語り口で投稿している、のは伺える。同じようなことばっかり言っていて不自然さはあるけれど…。

返信も試してみた。めっちゃ業務情報おしえてくれる。

こっちはやはり異様にフレンドリー。

MCP serverを自作することについて

欲しいMCP serverが既にあって利用できれば文句ないが、セキュリティの懸念などもあるし自作できるにこしたことはない。 今回は以下の理由もあり自作が最適解となった。

Rustでビルドし配布できる

GitHub Actionsからローカルで立ち上げてstdioで通信する場合、コマンドを指定することになる。対象のリポジトリをcloneしてきた上で、TypeScriptならさらにそれをbuildする必要があったりと 少し面倒。

Rustならビルド済みのバイナリを配布できるので、 Releases で置いておけばそれを1つダウンロードするだけでスッと使えて便利。 (勿論自分で作っているものなので安心して使える、というのはあるけど。)

LLMは使いこなしてくれない

BlueskyのMCPを作るにあたって、当初は「基本的なAT ProtocolのXRPCをラップするだけのもの提供すれば あとはそれを組み合わせて使ってもらえるはず」と考えていたが、それではダメだった。

例えば「受け取った返信に対して返信を投稿する」という行動をしてもらおうとすると、以下の操作と判断が必要になる:

  • 自分あての通知を取得する (listNotification)
    • reply, mention などに絞って取得することはできる
  • それぞれのpostに対して「自分がまだ返信していないもの」を抽出
    • getPostThread で該当postについているrepliesを確認できる
  • 未返信のものにだけreplyを投稿する

書きだすとこれだけだが、LLMはこういう「○○のときだけ△△する」「○○してから△△する」のような前提条件や順序をちゃんと理解してくれず、ステップをすっ飛ばして「△△する」だけ実行してしまったりすることがよくある。

絶対に○○のときだけにしてください」「必ず○○してから」と強く言ってもやはり確率的に漏れてしまい、プロンプトエンジニアリングでは防ぎきれなかった。MCPからPromptsを提供してみたりもしたがダメだった。 高性能のLLMを使ったり今後どんどん進化してくれればちゃんと言った通りに動いてくれるようにはなっていくとは思うが、おそらく100%防ぐ、というのは出来ないのではないかな…。

これは返信については致命的で、一度受け取ったreplyに対し何度も同じような返信をしてしまうことになる。

なので、MCPで提供するToolsとして get_unreplied_mentions を追加した。MCP server側のプログラムで listNotification を呼んで、それぞれに getPostThread 呼んでフィルタリングした結果を返す、というだけのもの。 これをLLM呼んでもらえば、そもそも未返信のものしか取れないので重複返信するようなことは防げる。

プログラムなら命令は順番通りに実行されるしif文書けば確実に制御できるが、LLMにはそういう概念はない。 小さな機能を提供し それを組み合わせて使う、といったソフトウェア設計哲学的な考え方はここでは通用しない、と感じた。

その他にも、indexedAt などの時刻がUTCで返ってくるので「データに含まれる時刻はJSTに換算して計算してください」と言ってもやはり漏れて間違ってしまったりもするのでMCP側でローカル時刻に変換して返すようにする、などの処理を入れている。

こういった細かいカスタマイズが必要になったりするので、同じサービス扱うMCP serverでも用途に応じて複数の実装が存在していて当然だし 自分の要求に合うMCP serverを作れるようになっておくのは重要だと感じた。

今後の課題とアイデア

通常の投稿と返信だけだが キャラクター付けして文体の差異は作れることが確認できた。 文体だけでなく「判断」「行動」まで作れる、というのはLLMの強みだろう。

フォローバックしたユーザのタイムラインから「面白いと思ったものにLikeしたり自発的にReplyする」は可能だろうか? 他人の投稿に対してどういう感想を持つのだろうか、というのは気になるところではある。

あとは最新の外界の情報を能動的にとれるようになっているとリアリティは増すだろうか(例えば居住地の天気情報や話題のニュースなど)。

時系列を繋いで整合性の取れた発言ができるというのも大事だと思っていて、「明日はオフ〜♪」と発言したなら翌日はちゃんと遊んでる、とか。 自分の過去の発言を読んだ上で投稿する、というプロンプトにしているのである程度は担保されそうだが、いっそ「日記」を"事前に"生成しておくのもアリかもしれない、と思っている。 例えば毎朝「その日の行動、起きる出来事、どんな一日になるか、SNSにアクセスする時間帯」などをもうLLMで生成してしまうことができるはずで、それに基づいて投稿する、という仕組みにする。より一貫性のある発言ができるようになる気はする。

一番どうにかしたいのは、知識について。 プロフィールに書いたことしかインプットされないのでやはり偏りがあるし専門知識が足りない。 プリキュア全シリーズ好きなはずなのにひたすらスマプリのことばかり呟いてしまうし、最新作のキミプリについてはまったく知識が無い。オタクとしてあるまじき失態。

このあたりはRAGなどで何かしら外部に情報を蓄えるものを各人格用に作り込む必要がありそう…? 自分に必要な情報源を自ら選んで、取り込んで使用してくれるようにしたいところ。

毎週日曜朝に一緒にプリキュアリアタイ視聴で盛り上がれる日は来るだろうか。

まとめ

Bluesky MCP serverを自作し、SNS上に友達を作る実験をしました。 まだまだ簡単には人間と見分けがつかないような感じにはならないけど、この先どんどんLLMが進化していくとどうなっていくでしょうか。 我々がSNS上でやり取りしている友達、と思っている人は既にLLMが作り出している架空の人格かもしれません。

Repository

Rust製AT Protocolライブラリ「ATrium」をorganizationに移管した

作りはじめた記事を書いてから約2年が経った。

memo.sugyan.com

開発を続けてきたRustライブラリ ATrium を、先日ついにorganizationに移管した。

github.com

経緯

昨夏からAT Protocolの OAuth に対応する実装を進めていたものの、プライベートの事情などもあってなかなか順調には進まなかった。何度かPull Requestをもらって実装方針を議論したりもしたが、進捗は芳しくなかった。

@erlend-sh 氏からDiscordで「organizationに移管したらどうか?」とアドバイスを受けたこと、また自分自身も継続的な開発・メンテナンスを強い意志で続けていける自信が持てなかったこともあり、前向きに検討を始めた。
そして先日ようやく atrium-oauth をリリースしたところで一区切りとし、自分の個人repositoryからorganizationへと移管。「メンテナンスに協力してくれる」と手を挙げてくださった4人のメンバーたちと一緒に運営していくことにした。

つい先日、同世代の友人の訃報を知るという出来事もあった。OSSライブラリが自分の死によってメンテナンスが止まってしまう、ということも現実のリスクとして無視できないと感じるようになった。

私とATrium

約2年前に X(当時はまだTwitter) を見限って新たな可能性を求めてBlueskyに参加した。AT Protocol というもので作られていることを知り、早速APIを叩いて何かをしてみたかったが、自分の好きなRust言語向けのライブラリはまだあまり良いものが無かった。 「これは挑戦してみるチャンス」と思い、さっそく自作に取り組んでみたのが始まりだった。

最初は atprs とか適当な名前だった。とにかく lexiconJSONをparseして、Rustの型に落とし込んでコードを生成することでAPIが使えるようになる、というものを目指して作った。あとはXPRC Requestを処理する部分を作って繋ぎ込み。

そしてChatGPTに相談してプロジェクト名を決定。

AT Protocolのための小さなライブラリが集まり青空を望む中庭、ということでATrium

このときは適当に決めたが、後々とても愛着の湧くものになった。

早速 Community Showcase にも登録してもらい、多くの開発者に注目していただいて、Pull RequestやIssueも届くようになった。 自分が全く知らなかったライブラリを教えてもらったり、新しい手法を提案してもらったりと、とても勉強になる日々だった。

実装を進めるにあたって様々な問題に遭遇し、試行錯誤を繰り返しながら解決策を模索していった。

依存しているライブラリで不具合があって そちらに Pull Request を送って対応してもらうこともあった。 Release-plz を導入してリリースフローを整備し、互換性を考慮しながら設計を必死に練り直し、寄せられた要望や問題に対応していった。

ドッグフーディングも兼ねて、自分でもATriumを使ってRustで botを作ったりDesktop ClientTUI Client を開発してみた。

そうしているうちに、多くの方々の協力を得て、Starも300を超えるまでに成長した。公式SDKではないものの、AT ProtocolのRustライブラリとしては事実上の標準的な存在になり、多くのRustプロジェクトで活用されるようになった。 自分が手がけたOSSがここまで広く使われるようになったのは、これが初めての経験だった。

もちろん良いことばかりではなく大変なこともあったけど、それらも含めて非常に貴重な経験ができた、と思う。

得たもの

ATriumの開発を通じて多くの学びがあり、Rustaceanとして間違いなく技術力が向上したと思う。特に非同期処理の制御や、Genericsを活用してユーザが柔軟に実装を差し替えられる構成にした点は、苦労の連続ではあったが その分大きな学びとなった。そして依存ライブラリやfeatureについてよく調べたり、他のSDKの実装を読みこんだりもした。

またProtocolの実装のために、AT ProtocolのspecやOAuth関連のRFCを何度も調べたり読み込んだりしたのも良い経験になった。

何より、ATriumというOSSの開発を通じて多くの開発者の方々と交流できたことが財産になっている。開発者コミュニティDiscordなどにも入れていただいて、多くのアドバイスや情報をいただけるようにもなった。

今後

もちろん、organizationに移管したからといって開発を一切放棄するというわけではなく、メンテナの一員として対応できるものがあればやっていくし、自分自身でもまだ使い続けるので必要と思った修正や更新は積極的に入れていくつもり (もちろん他のメンテナから承認もらった上で)。
ただ「自分が対応しないと何も進まない」という状態からは脱することができて 少し安心している。

さらに多くの人々に使っていただけるOSSプロダクトとして成長していけるよう、無理の無い範囲で関わっていけたらいいなと思っています。

apprun-cliを使ってさくらクラウドAppRun β版をActionsで自動deployする

触ってみたという記事がいくつか出ていて、自分も気になったので触ってみました。

さくらのクラウド AppRun β版 とは

manual.sakura.ad.jp

関連記事

作ったもの

github.com

Rust + Axum で最小構成なHello, world!するだけのものです。後述の通りバージョン情報を取るエンドポイントだけ追加しています

アプリケーション作成

マニュアルにある通り、AppRunアプリケーションの作成には最低限以下の作業が必要です。(さくらのクラウドのアカウント作成、電話番号認証などは済んでいる前提)

  1. (事前準備) Webアプリケーションを作成して、コンテナイメージをビルド
  2. さくらのクラウドにコンテナレジストリを作成
  3. 作成したレジストリにコンテナイメージをプッシュ
  4. AppRunアプリケーションを作成

Terraformで作成

普通にWeb UIをポチポチして作業しても出来るけど、Terraformで作成する方法もあるそうなので、極力コントロールパネルを開きたくない人向けに一応書いてみました。

https://github.com/sugyan/apprun-example/tree/main/tf

適当に コンテナレジストリの名前、ユーザのパスワードだけ .tfvars に書いて実行すれば とりあえず構成だけは作れるはずです。

ただこれは単に「コンテナレジストリを作ってそれをAppRunで参照する構成を作る」というだけなので、実際にイメージをビルドしてプッシュする部分は別途必要になります。上記のものでアプリケーションは作成されるけど、実際にはレジストリに何もイメージがないので最初のデプロイは当然失敗します。

apprun-cli でデプロイ

ともあれ構成さえ作ることができれば、あとはCLIで諸々の操作ができるはず。 id:sfujiwara さんの便利CLIを使わせていただきます。

github.com

Jsonnetで現在の設定を引いてきて、それを適宜編集して deploy コマンドに渡すことで新しい設定を反映させることができるようです。

apprun-cli init --name my_web_app --jsonnet > myapp.jsonnet
# edit myapp.jsonnet
apprun-cli deploy --app myapp.jsonnet

Actionsで自動デプロイ

ここまでできるのであれば、あとはもう 「イメージのビルド→プッシュ→デプロイ」を自動化できるはず。

ということでGitHub Actionsでやってみました。

https://github.com/sugyan/apprun-example/blob/main/.github/workflows/deploy.yml

Jsonnetで環境変数から CONTAINER_REGISTRY_IMAGE を使うよう設定しておき、

main branchにpushされたら、

  • Repository を checkout
  • Dockerfile から image を build&push
  • push された image の digest を CONTAINER_REGISTRY_IMAGE にセット
  • apprun-cli でデプロイ

という流れで、自動で最新のコードがAppRunに反映されるようになりました。

バージョン情報を含める一工夫

実際に動いているWebAppが本当に最新のものか分からなくなりがちなので、metadata-action を使って最新の commit の hash や date を取得し、 labelsbuild-args でイメージや環境変数に埋め込むようにしています。

これによってランタイム上で GET /version すると、build した時の commit hashや日付が返ってくるようになりました。

まとめ

とりあえず 2025年2月現在の AppRun β版 での簡単なデプロイ自動化の例を書いてみました。他にも便利な方法をご存知の方がいれば教えてください。 もっと便利にやっていけるよう貢献できるところがあればやっていきたいと思います。

自宅環境監視への入門

どうも自分は監視についての知識や技術が乏しい、ということに気付いた。 そもそも何かを監視するという習慣を持っていないのがまず良くないと反省し、まずは自宅の環境を監視する練習から始めてみることにした。

監視とは役割ではなくスキルであり、チーム内の全員がある程度のレベルに至っておくべきです。

入門 監視 ―モダンなモニタリングのためのデザインパターン


環境

国内のクラウドサービス でサーバを借りることにしたので、そこに環境を整えていく。

Ansible from macOS

サーバ上で設定を編集して再起動したりとかをしたくない。すべて手元のMacBookからAnsibleで設定していくことにした。

とはいえmacOSからのAnsible実行にはいくつかの罠があるようだった。以下は自分がハマったもの。

$ brew install gnu-tar
$ export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES

Ansible Galaxy

Ansibleにも不慣れで、できるだけ自分でYAMLを書かずに先人の知恵を借りていきたい。 極力Galaxyを使ってやっていくことにした。

galaxy.ansible.com

$ ansible-galaxy collection install prometheus.prometheus
$ ansible-galaxy collection install victoriametrics.cluster
$ ansible-galaxy collection install grafana.grafana
$ ansible-galaxy collection install community.general
$ ansible-galaxy collection install community.grafana

VictoriaMetricsによる監視

監視の定番は Prometheus かもしれないが、ここでは VictoriaMetrics を使ってみることにした。

victoriametrics.com

Prometheusとの互換性を持ちつつも 収集・保存・クエリ・アラートなどがそれぞれ独立したコンポーネントとして提供されていて、必要に応じて組み合わせて使うことができる。そしてそれらはクラスタ化してスケールアウトする運用もしやすくなっているようだ。

個人で1台のサーバで使うぶんには Single-node version で十分で、これは vminsert, vmselect, vmstorage を一つにまとめたもの、になっているようだ。 結局Prometheusを立てるのとそんなに変わらないのかもしれないが、データの長期保存に向いているなどの特徴もあるようなので、メリットは十分にありそう。

Metricsの取得、収集

VictoriaMetrics自体は何かのmetricsを取れるわけではないので(自身のプロセスのmetricsは取れるが)、それこそPrometheusコミュニティで使われているようなExporterを使っていくことになる。

Node Exporter

まずは定番のNode Exporter。 Galaxyで提供されているのでroleを参照するだけ。

- name: Install node_exporter
  hosts: servers
  roles:
    - prometheus.prometheus.node_exporter

これでport :9100node_exporter が起動し、metricsを取得できるようになる。

VictoriaMetrics (vmsingle, vmagent)

それからVictoriaMetricsを立ち上げ、Node Exporterからのmetricsを収集していく。 Single-node version の vmsingle にも収集機能はあるようだが、ここでは別プロセスで vmagent を動かすようにする。

- name: Install monitoring tools
  hosts: monitoring
  become: true
  roles:
    - role: victoriametrics.cluster.vmsingle
    - role: victoriametrics.cluster.vmagent
      vars:
        vmagent_scrape_config:
          scrape_configs:
            - job_name: node-exporter
              static_configs:
                - targets:
                    - 127.0.0.1:9100

これでport :8428victoriametrics が起動し、また vmagent--remoteWrite.url=http://localhost:8428/api/v1/write というオプションで起動するので、上記 scrape_configs に従って node_exporter のmetricsを定期的に収集し、 victoriametrics に書き込むようになる。

:8428/vmui/ にアクセスするとGUIでデータを確認できる。

SwitchBotによる温度/湿度/CO2濃度の取得

実際に自宅の環境を監視していくためのデータ取得。

センサ機器は Raspberry Pi とか使って自作すれば良いのかもしれないけど、手軽に始めたかったのでとりあえず SwitchBot のものを購入してみた。

これ一つで温度、湿度、CO2濃度を取得できる。が、後で知ったのだけど単体ではスマホアプリからBluetoothで繋いでデータを閲覧するくらいで、外部からAPIでデータを取得するようなことはできない。別途ハブを購入して そちらからインターネット接続することで実現できる。

赤外線操作の機能もあるらしいが、うちのテレビやエアコンに試してみた限りでは上手く動かず…。CO2濃度にこだわらなければ Nature Remo を買った方がよかったかも?

Exporter

ともかくセンサとハブが繋がっていれば、Open Token, Secret Keyを使って SwitchBot API でデータを取得できるようになる。

このAPIを使うための便利ライブラリが既に公開されていて、

github.com

さらにそれを使ってPrometheus向けのmetricsを出力してくれるExporterまで公開されている!

github.com

有り難くこれを使わせていただくことで、1行もコードを書かずにSwitchBotのセンサデータをmetricsとして収集することができるようになる。

とはいえ、CO2の値の取得に対応されたのは数ヶ月前で、その変更は最新のリリースには含まれていないし Linuxで動作するバイナリも配布はされていないので、main branchからLinux向けのバイナリを自分でビルドする必要がある。

#!/bin/bash

REPO_URL="https://github.com/nasa9084/switchbot-exporter.git"
OUTPUT_BINARY="switchbot-exporter_linux_amd64"

git clone --depth=1 --branch=master "${REPO_URL}"
GOOS=linux GOARCH=amd64 go build -o "${OUTPUT_BINARY}"

あとはこのバイナリを送り、systemdで起動するように設定する。

- name: Send switchbot-exporter binary
  ansible.builtin.copy:
    src: "./switchbot-exporter_linux_amd64"
    dest: "{{ switchbot_exporter_binary_path }}"
    mode: "0755"

- name: Create switchbot-exporter envfile
  ansible.builtin.template:
    src: "./switchbot-exporter.env.j2"
    dest: "{{ switchbot_exporter_envfile_path }}"
    mode: "0644"
  register: envfile

- name: Create switchbot-exporter service
  ansible.builtin.template:
    src: "./switchbot-exporter.service.j2"
    dest: "/etc/systemd/system/switchbot-exporter.service"
    mode: "0644"
  register: service

- name: Reload systemd if envfile or service changed
  when: envfile.changed or service.changed
  ansible.builtin.systemd:
    daemon_reload: true
  notify: Restart switchbot-exporter service

- name: Start switchbot-exporter service
  ansible.builtin.systemd:
    name: switchbot-exporter
    state: started
    enabled: true
  when: not ansible_check_mode | bool

-web.listen-address で指定したアドレスでmetricsが取れるようになるので、 vmagentscrape_configs に追加してやれば良い。

    - role: victoriametrics.cluster.vmagent
      vars:
        vmagent_scrape_config:
          scrape_configs:
            - job_name: node-exporter
              static_configs:
                - targets:
                    - 127.0.0.1:9100
            - job_name: switchbot
              metrics_path: /metrics
              static_configs:
                - targets:
                    - B0E9FE56AF0D
                    - D53535302D51
              relabel_configs:
                - source_labels: [__address__]
                  target_label: __param_target
                - source_labels: [__param_target]
                  target_label: instance
                - target_label: __address__
                  replacement: {{ switchbot_exporter_address }}

設定例も README に書いてあり、こうすることで targets に指定したDevice IDを instance として取得できるようになるらしい。

Grafanaによる可視化

無事にSwitchBotで取得した温度・湿度・CO2濃度のデータをVictoriaMetricsで収集、保存ができるようになったので、次はそれを可視化していく。

ここは定番のGrafanaで。

- name: Install grafana
  hosts: monitoring
  become: true
  roles:
    - role: grafana.grafana.grafana
      vars:
        grafana_ini:
          server:
            ...
          security:
            admin_user: admin
            admin_password: ...

最低限の設定で起動してWebUIにアクセスできるようになるので、そこでDashboardを作成していく。 互換性のおかげで、ちゃんと Prometheus datasource としてVictoriaMetricsを指定できる。

温湿度計だけのものも併用して別の部屋に置いているので、2つの部屋の状態の違いなども一目でわかるようになっている。

Alertmanagerを使ったアラート通知

あとはアラート、通知送信。

CO2濃度の値が一定以上になったらアラートを出すようにする。 その対応は簡単に自動化できるものでもないので、とりあえずSlackの適当なChannelに通知を送信するようにする。

Grafanaにもアラート機能があるようだけど、ここでは Prometheusの Alertmanager を使うことにする。 (機能の違いなどをまだ把握していない)

Alertmanagerのインストール

- name: Install monitoring tools
  hosts: monitoring
  become: true
  roles:
    - role: prometheus.prometheus.alertmanager

デフォルトで :9093 で起動してWebUIにもアクセスできるようになる。

vmalert によるアラート

VictoriaMetricsではアラートを出すためのコンポーネントvmalert として独立したものが提供されている。

    - role: victoriametrics.cluster.vmalert
      vars:
        vic_vm_alert_rules:
          - name: SwitchBot
            rules:
              - alert: HighCO2LevelWarning
                expr: switchbot_meter_CO2 > 1200
                for: 5m
                labels:
                  severity: warning
                annotations:
                  summary: "CO2 level exceeds 1200 ppm (Warning)"
                  description: "{% raw %}The current CO2 level is {{ $value }} ppm.{% endraw %}"
              - alert: HighCO2LevelCritical
                expr: switchbot_meter_CO2 > 1800
                for: 5m
                labels:
                  severity: critical
                annotations:
                  summary: "CO2 level exceeds 1800 ppm (Critical)"
                  description: "{% raw %}The current CO2 level is {{ $value }} ppm.{% endraw %}"

これもデフォルトで --datasource.url として VictoriaMetrics (vmsingle) の http://localhost:8428 を、--notifier.url として Alertmanager の http://localhost:9093 を指定して起動するようになっているので、ruleだけ設定すればアラートが発報されるようになる。

とりあえず2つの閾値を設定し、 warningcritical の2種類の severity をlabelにつけている。 CO2濃度は 1,000 ppm 以下が基準となっているようだけど、閾値が低すぎると我が家の環境では結構頻繁にアラート出てしまうのでちょっと調整している…。

Alerting rulesなどは {{ $value }} といったテンプレートを使用でき、それをAnsibleから指定しようとするとJinja2のテンプレートと混ざらないよう気をつける必要があり、まぁまぁ面倒…。

Slackへの通知設定

改めてAlertmanagerの設定。

receivers:
  - name: noop
  - name: slack_critical
    slack_configs:
      - api_url: https://hooks.slack.com/services/...
        color: danger
        text: "{{ .CommonAnnotations.description }}"
        title: "{{ .CommonAnnotations.summary }}"
  - name: slack_warning
    slack_configs:
      - api_url: https://hooks.slack.com/services/...
        color: '{{ if eq .Status "firing" }}warning{{ else }}good{{ end }}'
        send_resolved: true
        text: '{{ if eq .Status "firing" }}{{ .CommonAnnotations.description }}{{ else }}Resolved.{{ end }}'
        title: "{{ .CommonAnnotations.summary }}"

inhibit_rules:
  - equal:
      - instance
    source_match:
      severity: critical
    target_match:
      severity: warning

route:
  receiver: noop
  routes:
    - match:
        severity: critical
      receiver: slack_critical
    - match:
        severity: warning
      receiver: slack_warning

送信先api_url は同一だが warning 用と critical 用で設定を変えて、 warning の方は解決したときにもその旨を通知するようにしている。

これで、常にGrafana Dashboardを眺めていなくても自宅の環境が異常になっていることを通知によって気付くことができるようになった。

まとめ

監視に馴染みがなく不慣れながらも、どうにか自宅の環境を監視する環境を整えることができた。

これからも継続して運用・改善していきつつ、他にも監視対象にできるものがあれば追加したりしていきたい。