前回、LINEからSlackに乗り換えてClaude Codeを動かせるようにした話を書きました。あれから一ヶ月ほど経って、最初は「とりあえず動く」くらいだったものを、毎日触りながら少しずつ機能を足してきました。今日はその続きの話をします。
きっかけは、人に見せた瞬間に詰まったこと
ちょうどSHISHAMOのライブで関東に行く用事があって、せっかくなのでゼミ仲間に会ってきました。そこで「最近こんなの作ってるんだよ」と、例のSlackボットを実物で見せたんです。スマホからメッセージを投げると、自宅のサーバーでClaude Codeが動いて返事が返ってくる。我ながらちょっと自慢したくなる仕組みでした。
ところが、いざ披露という場面でテーマの切り替えがうまく動かなくて。私のボットはブログ用と金融用みたいに作業対象を切り替えられるようにしているんですが、その切り替えがその場で一瞬詰まったんですね。人に見せると、自分一人で使っているときには気づかない引っかかりがはっきり見えてきます。結果的にこの一連が、ちょうどいい「総合テスト」になりました。家に帰ってから、直したいところと足したい機能が一気に明確になったんです。
おさらい:ボットの基本構造
機能の話に入る前に、土台になっている仕組みを簡単に。前回作ったときのコードはこんな形でした。Slackのイベントを受け取って、署名を検証して、すぐ200を返してから、裏でClaude CodeのCLIを叩いて結果をSlackに送り返す、という流れです。
app.post('/slack/events', (req, res) => {
if (req.body.type === 'url_verification') {
return res.json({ challenge: req.body.challenge }); // ① URL検証
}
if (!verifySlackSignature(req)) return res.sendStatus(403); // ② 署名検証
const event = req.body.event;
if (!event || event.type !== 'message' || event.bot_id) {
return res.sendStatus(200); // ③ 自分の発言などは無視
}
res.sendStatus(200); // ④ 先に200を返す(タイムアウト回避)
const result = execSync(
`claude --print "${event.text.replace(/"/g, '\\"')}"`,
{ cwd: '/path/to/repo', timeout: 120000 }
).toString(); // ⑤ Claude Code を実行
postToSlack(event.channel, result); // ⑥ 結果を返信
});
ここからの機能追加は、ほとんどがこの⑤ claude --print …の前後をいじる話になります。「どのモデルで」「どのフォルダで」「どう途中経過を見せながら」実行するか、という枝を足していった、というイメージです。以下、それぞれを処理の流れと一緒に紹介します。
トークンの枠を、最後まで使い切りたかった
まず取りかかったのがモデルの切り替えです。Claude CodeにはOpus、Sonnet、Haikuと性能の違うモデルがあって、賢いほど消費も大きい。私の契約しているプランには週単位の枠と、それとは別に5時間単位の枠があるんですが、これを余らせるのがどうにももったいない。
そこで、状況に応じてモデルを選べるようにしました。枠に余裕があるときは迷わずOpusで賢く動かして、節約したいときはHaikuに落として軽く回す。Sonnetはちょうどあいだのバランス役です。要は「枠をきれいに使い切る」ことが目的で、これができるようになってから、残量を気にしてビクビクする感覚がなくなりました。
処理としては難しいことはしていません。Claude CodeのCLIには--modelというオプションがあるので、Slackのメッセージの先頭で指定したモデル名を拾って、そのまま渡しているだけです。
// 例: 「opus: ログ見て」「haiku: この設定どうなってる?」のように先頭で指定
function parseModel(text) {
const m = text.match(/^(opus|sonnet|haiku)\s*[::]\s*/i);
if (!m) return { model: 'sonnet', body: text }; // 指定なしは既定モデル
return { model: m[1].toLowerCase(), body: text.slice(m[0].length) };
}
const { model, body } = parseModel(event.text);
const result = execSync(
`claude --print --model ${model} "${body.replace(/"/g, '\\"')}"`,
{ cwd: currentDir, timeout: 120000 }
).toString();
流れにすると、こうなります。
Slackメッセージ受信
│ 先頭の "opus:" などを判定
▼
モデルを決定(指定なし → 既定)
│
▼
claude --print --model <model> を実行
│
▼
結果をSlackに返信
そして、ライブで一瞬詰まったテーマの切り替えも仕組みはこれと同じです。モデルの代わりに「どのフォルダ(リポジトリ)で動かすか」を切り替えていて、execSyncのcwdを差し替えているだけ。ブログ用と金融用で作業ディレクトリが違うので、コマンドでcurrentDirという状態を書き換えてから実行する形にしています。詰まったのは、この「今どっちのフォルダにいるか」がメッセージから分かりにくかったのが原因で、いまは切り替え時に現在地をSlackに返すようにしました。
スマホのチャットは、思っていたより消費が早い
使い続けて気づいたのが、VS Codeの拡張で動かすより、Slack経由のほうがトークンの消費が早いということでした。やりとりが会話形式で積み重なっていくぶん、どうしても膨らみやすいんですね。
なので節約のための仕掛けもいくつか入れました。軽いモデルを普段使いにしておくのもそうですし、会話が長くなってきたら自動で要約してコンテキストを圧縮する仕組みも足しています。このあたりは「賢く動かす」というより「無駄なく動かす」ための工夫で、地味ですが効きます。
会話を続けて使うので、Claude Codeのセッションを毎回作り直すのではなく、チャンネルごとにセッションIDを覚えておいて--continueでつなげています。やりとりが長くなって重くなってきたら、一度要約してから新しいセッションに引き継ぐ、という処理を挟むようにしました。
const session = sessions[event.channel] || {};
session.turns = (session.turns || 0) + 1;
// 一定のやりとり数を超えたら、これまでの流れを要約して圧縮
if (session.turns > THRESHOLD) {
const summary = execSync(
`claude --print --model haiku "ここまでの会話を3行で要約して"`,
{ cwd: currentDir }
).toString();
session.context = summary; // 重い履歴を捨て、要約だけ持ち越す
session.turns = 0;
}
sessions[event.channel] = session;
ポイントは、要約そのものをいちばん軽いHaikuにやらせているところです。「節約のための処理で枠を食う」と本末転倒なので、安く済む仕事は安いモデルに回す。流れとしては「やりとりが一定数を超えたら→Haikuで要約→以降は要約だけ持ち越す」という分岐を、本処理の手前に一段かませているだけです。
動いているのか分からない、を解消する通知
それから、これは結構ストレスだったんですが、メッセージを投げたあとボットがちゃんと動いているのか分からない時間があったんです。返事が来るまで沈黙で、固まっているのか考えているのか判断がつかない。
そこでモバイルに進捗を出すようにしました。今どこまで進んでいるかがスマホに表示されるだけで、待っている間の安心感がまるで違います。人に見せたときに詰まった件も、半分はこの「状態が見えない」問題でした。
ここはコードの書き方が一段変わりました。前回はexecSyncで「実行し終わるまで待つ」作りだったので、終わるまで何も言えなかったんです。これをspawnに変えて、Claude Codeの出力を一行ずつ受け取れるようにして、節目でSlackに途中経過を流すようにしました。
const { spawn } = require('child_process');
postToSlack(event.channel, '受け付けました。処理中です…'); // 受付通知
const child = spawn('claude', ['--print', '--model', model, body], { cwd: currentDir });
let buffer = '';
child.stdout.on('data', chunk => {
buffer += chunk.toString();
// ツール実行や区切りのタイミングで途中経過を送る
if (/(実行中|完了|→)/.test(chunk.toString())) {
postToSlack(event.channel, `…${chunk.toString().trim()}`);
}
});
child.on('close', () => {
postToSlack(event.channel, buffer.trim()); // 最終結果
});
流れにすると、沈黙だったところに「受付」と「途中経過」が挟まる形です。
メッセージ受信
▼
「受け付けました。処理中です…」を即送信 ← 追加
▼
claude をspawnで起動し、出力を逐次受信
▼
節目ごとに途中経過をSlackへ ← 追加
▼
完了したら最終結果をSlackへ
やっていることは単純ですが、「黙って待たされる」から「実況が流れてくる」に変わるだけで、体感はまるで別物になりました。
スクショを投げられるようにした
最後に添付ファイル対応です。きっかけは、まさにそのSHISHAMOのセットリストでした。ライブのあと、セトリのスクショをそのままボットに投げて処理させたくなったんですね。テキストだけじゃなくて画像を渡せると、できることが一気に広がります。今では画面のスクショを撮ってそのまま投げる、という使い方が当たり前になりました。
仕組みとしては、Slackのイベントに添付ファイルが含まれていたら、その画像を自宅サーバーに一度ダウンロードして、ファイルのパスをClaude Codeに渡しています。Slackの画像は認証付きのURL(url_private)なので、ダウンロードのときにBotトークンを付けてあげる必要があるのがちょっとした注意点でした。
// event.files に添付がある場合は画像を取得してから Claude に渡す
let imagePaths = [];
if (event.files && event.files.length) {
for (const f of event.files) {
const resp = await fetch(f.url_private, {
headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` }, // 認証が必要
});
const path = `/tmp/${f.id}_${f.name}`;
fs.writeFileSync(path, Buffer.from(await resp.arrayBuffer()));
imagePaths.push(path);
}
}
// 画像のパスを添えてClaude Codeに渡す
const args = ['--print', '--model', model, body, ...imagePaths];
const child = spawn('claude', args, { cwd: currentDir });
流れはこうです。
スクショ付きでSlackに投稿
▼
event.files から url_private を取得
▼
Botトークンを付けて画像をサーバーへダウンロード
▼
保存したパスを引数に足して claude を実行
▼
画像を読んだ結果をSlackへ返信
セトリのスクショを投げると、そのまま曲名を読み取ってブログ用に整形してくれる。テキストを打ち直す手間が消えて、これが地味にいちばん日常で使っています。
マニアの道具から、日常の道具へ
ここまで足してきて思うのは、Slackでスマホからチャットする方式が、結局いちばんフルに使えるということです。PCの前に座って拡張機能を立ち上げて……という構えがいらない。ふと思いついたときにスマホから投げれば、家のサーバーが勝手に働いてくれる。
なんというか、自宅に初めてインターネットが来たときのような手応えがあります。最初は一部のマニアの遊びだったものが、気づけば毎日の生活に溶け込んでいる。あの感覚に近い。
そしてもうひとつ強く感じるのは、「考え方さえ分かれば、実装はClaudeに作らせられる」ということです。今回足した機能も、私が一行ずつ書いたというより、こうしたい、という方向を伝えて形にしてもらったものがほとんどです。アイデアと段取りさえ自分の中にあれば、手を動かす部分はかなり任せられる。そういう時代になったんだなと、自分の道具を育てながら実感しています。