この記事で得られること
- AIエージェントにDiscord Botを繋いでスマホから操作する実装方法がわかる
- Discord.jsとClaude Codeを連携させる具体的なコードを入手できる
- 外出先からエージェントに指示を送る運用フローを構築できる
対象読者: AIエージェントをスマホから操作したい方 / Discord Botの開発に興味がある方
なぜDiscordなのか
自律AIエージェントを構築して運用していると、ある問題にぶつかる。エージェントとのインターフェースがターミナルしかないという問題である。
PCの前にいるときはそれでいい。しかし外出中にタスクの進捗を確認したい、急ぎの指示を飛ばしたい、エージェントからの報告を受け取りたい——こうした場面でターミナルは使えない。
この問題を解決するために、自律AIエージェント「Sentinel」にDiscord Botを連携させた。Discordを選んだ理由はシンプルである。
- スマホアプリがある: iOS/Android両対応。外出先からメッセージを送受信できる
- Webhookがある: エージェント側から一方的に通知を送れる。Bot不要で使える
- プライベートサーバーが無料で作れる: 自分専用のエージェント操作チャンネルを作れる
- リッチな表示: Embed(埋め込みメッセージ)で構造化された情報を見やすく表示できる
SlackやLINEでも同じことはできるが、個人開発ではDiscordが最も手軽である。
全体アーキテクチャ
Sentinelでの構成はこうなっている。
Discord(スマホ/PC)
↕ Discord API
Discord Bot(bot.js)
↕ HTTP API
sentinel.js(エージェントのランタイム)
↕
Claude Code(LLMによる思考・判断)
Discord Botはエージェントのフロントエンドにすぎない。メッセージを受け取ってエージェントに渡し、エージェントの応答をDiscordに返す。ビジネスロジックはBot側には持たせない。
この分離が重要である。Botが落ちてもエージェントは動き続けるし、エージェントが止まってもBotは通知を受けられる。
Step 1: Discord Botの作成
Discord Developer Portalでの設定
- Discord Developer Portal にアクセス
- 「New Application」でアプリケーションを作成
- 左メニューの「Bot」を開く
- 「Reset Token」でBotトークンを取得(このトークンは一度しか表示されない。必ず控えておく)
Intentsの設定(最重要)
同じ「Bot」ページの下部にPrivileged Gateway Intentsがある。ここで以下を有効にする。
- MESSAGE CONTENT INTENT: メッセージ本文を読むために必須
これを有効にしないと、message.contentが常に空文字列になる。Bot自体は動くし、メッセージイベントも発火する。しかし肝心のメッセージ内容が取れない。最もハマりやすいポイントである。
Botをサーバーに招待
- 左メニューの「OAuth2」→「URL Generator」を開く
- SCOPESで「bot」にチェック
- BOT PERMISSIONSで以下にチェック:
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions
- 生成されたURLをブラウザで開き、自分のサーバーに招待
Step 2: プロジェクトのセットアップ
mkdir discord-bot && cd discord-bot
npm init -y
npm install discord.js dotenv
package.jsonで"type": "module"を追加しておく。ES Modulesのimport構文を使うためである。
{
"name": "sentinel-discord-bot",
"type": "module",
"dependencies": {
"discord.js": "^14.25.0",
"dotenv": "^16.4.0"
}
}
.envファイルにトークンを置く。
DISCORD_TOKEN=your-bot-token-here
SENTINEL_DIR=C:/Users/yourname/agent
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxxx/yyyy
Step 3: 基本的なBotの実装
最小構成から始める。メッセージを受け取って応答するだけのBotである。
import 'dotenv/config';
import { Client, GatewayIntentBits } from 'discord.js';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, // これがないとmessage.contentが空になる
],
});
client.once('ready', () => {
console.log(`Bot起動: ${client.user.tag}`);
});
client.on('messageCreate', async (message) => {
// Bot自身のメッセージは無視
if (message.author.bot) return;
if (message.content === '!ping') {
await message.reply('pong');
}
});
client.login(process.env.DISCORD_TOKEN);
GatewayIntentBits.MessageContentをintents配列に含めることが必須である。Developer Portalでの有効化とコード側での指定、両方が必要だということを覚えておくこと。
Step 4: エージェントとの接続
ここからが本題である。Discord Botをエージェントのフロントエンドとして機能させる。
方式A: エージェントのHTTP APIに送信する
Sentinelではsentinel.jsがHTTP APIを公開している。BotはこのAPIにメッセージをリレーする。
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
const text = message.content.trim();
if (!text) return;
const SENTINEL_API = process.env.SENTINEL_API_URL || 'http://localhost:3100';
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
try {
const res = await fetch(`${SENTINEL_API}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: `【Discordからのメッセージ(${message.author.username})】\n${text}`,
callback_url: webhookUrl, // 応答をWebhookで返してもらう
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`API ${res.status}`);
await message.reply('考え中...');
} catch (err) {
await message.reply(`エージェント送信エラー: ${err.message}`);
}
});
ポイントはcallback_urlである。エージェントの処理は数秒〜数分かかるため、同期で応答を待つとDiscordのタイムアウトに引っかかる。Webhook URLを渡しておけば、エージェントは処理完了後にそのURLに結果をPOSTしてくれる。
方式B: claude -pへのフォールバック
エージェントのランタイムが起動していない場合、claude -p(Claude CodeのCLI)を直接呼び出すフォールバックを用意している。
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
async function askClaudeDirect(question, cwd) {
const { stdout } = await execFileAsync(
'claude',
['-p', '--max-turns', '3', question],
{ timeout: 180000, cwd, maxBuffer: 1024 * 1024 }
);
return stdout.trim();
}
--max-turns 3で無限ループを防ぎ、timeout: 180000(3分)で暴走を止める。maxBufferはデフォルトだと長い出力で溢れるため、1MBに設定する。
Step 5: コマンド体系の設計
Sentinelでは!プレフィックスでコマンドを実装している。
const commands = {
status: async (message) => {
// TASKS.mdを読んでステータスを表示
const tasks = readFileSync('TASKS.md', 'utf-8');
const waiting = (tasks.match(/- \[ \]/g) || []).length;
const done = (tasks.match(/- \[x\]/g) || []).length;
const embed = new EmbedBuilder()
.setTitle('Sentinel ステータス')
.setColor(0x00FF00)
.addFields(
{ name: '待機中', value: `${waiting}件`, inline: true },
{ name: '完了', value: `${done}件`, inline: true },
)
.setTimestamp();
await message.reply({ embeds: [embed] });
},
tasks: async (message) => {
const content = readFileSync('TASKS.md', 'utf-8');
await message.reply(`\`\`\`\n${content.substring(0, 1800)}\n\`\`\``);
},
usage: async (message) => {
// トークン使用量をログファイルから集計して表示
// ...
},
};
// メッセージハンドラで振り分け
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
if (!message.content.startsWith('!')) return;
const [cmd, ...argParts] = message.content.slice(1).split(' ');
const args = argParts.join(' ').trim() || null;
const handler = commands[cmd.toLowerCase()];
if (handler) {
await handler(message, args);
}
});
Sentinelで実装しているコマンドの一覧を示す。
| コマンド | 機能 |
|---|---|
!status |
タスクの進捗状況を表示 |
!tasks |
TASKS.mdの内容を表示 |
!memory |
直近の作業記録を表示 |
!ask <質問> |
エージェントに質問を送信 |
!post <テキスト> |
X(Twitter)に投稿 |
!usage |
トークン使用量を表示 |
!なしの通常メッセージはすべて!askと同じ動作にしている。これにより、チャット感覚でエージェントと対話できる。
Step 6: Webhook通知(エージェント→Discord)
Botとは逆方向の通信——エージェントからDiscordへの通知——にはWebhookを使う。Botが起動していなくても通知を送れるのが利点である。
Webhookの取得
Discordサーバーの設定 → 連携サービス → ウェブフック → 新しいウェブフック → URLをコピー
通知スクリプト
// notify.js
import 'dotenv/config';
const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
async function sendNotification(title, body, color = 0x00FF00) {
const payload = {
embeds: [{
title,
description: body,
color,
timestamp: new Date().toISOString(),
footer: { text: 'Sentinel Notification' },
}],
};
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Webhook失敗: ${res.status}`);
}
// CLI引数で呼び出せるようにする
const [,, title, body] = process.argv;
if (title) sendNotification(title, body || '').catch(console.error);
これをcronやエージェントのランタイムから呼び出す。
# cronからの利用例(第1引数=タイトル、第2引数=本文)
node notify.js "cron巡回完了" "タスク3件処理済み"
Step 7: セキュリティ対策
プライベートサーバーとはいえ、最低限のセキュリティは入れておく。
ユーザー制限
const ALLOWED_USER_IDS = process.env.ALLOWED_USER_IDS
? process.env.ALLOWED_USER_IDS.split(',')
: []; // 空なら全員許可(プライベートサーバー前提)
function isAllowed(userId) {
if (ALLOWED_USER_IDS.length === 0) return true;
return ALLOWED_USER_IDS.includes(userId);
}
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
if (!isAllowed(message.author.id)) return;
// ...
});
.envにユーザーIDをカンマ区切りで指定する。空欄なら全員許可。プライベートサーバーで自分しかいないなら空でよい。
ファイル読み取りの制限
エージェントのファイルを読み取って表示する機能では、文字数制限を入れる。
function readSafe(filePath, maxLen = 1800) {
const fullPath = path.resolve(SENTINEL_DIR, filePath);
if (!existsSync(fullPath)) return '(ファイルが見つかりません)';
const content = readFileSync(fullPath, 'utf-8');
return content.length > maxLen
? content.substring(0, maxLen) + '\n...(省略)'
: content;
}
Discordのメッセージ上限は2000文字である。これを超えるとAPIエラーになるため、表示前に切り詰める。
ハマりどころまとめ
実装中にハマったポイントを整理しておく。
1. MESSAGE CONTENT Intentの罠
症状: Botは起動する。メッセージイベントも発火する。しかしmessage.contentが空文字。
原因: 2022年のDiscord API変更で、メッセージ内容の取得がPrivileged Intentになった。Developer Portalでの有効化とコード側でのGatewayIntentBits.MessageContent指定の両方が必要。
2. Discordメッセージの2000文字制限
症状: 長い応答を返そうとするとDiscordAPIError: Invalid Form Body。
対策: 応答を1900文字ごとに分割して複数メッセージで送る。
if (response.length > 1900) {
const chunks = response.match(/[\s\S]{1,1900}/g) || [];
for (const chunk of chunks) {
await message.channel.send(`\`\`\`\n${chunk}\n\`\`\``);
}
}
3. エージェント処理のタイムアウト
症状: エージェントの応答を同期で待つと、3分以上かかる処理でタイムアウト。
対策: 非同期にする。リクエスト送信時に「考え中...」と返し、処理完了後にWebhookで応答を返す。
4. Partialメッセージの扱い
リアクションイベントでは、キャッシュにないメッセージがpartialとして届くことがある。
client.on('messageReactionAdd', async (reaction, user) => {
if (reaction.partial) {
try { await reaction.fetch(); } catch { return; }
}
if (reaction.message.partial) {
try { await reaction.message.fetch(); } catch { return; }
}
// ...
});
Partials.MessageとPartials.ReactionをClient初期化時に指定し、イベントハンドラ内でfetch()するのがパターンである。
運用してみて
Discord Bot連携を入れてから、エージェントとの関わり方が変わった。
スマホからタスクの進捗を確認して、必要なら追加指示を出す。エージェントがcron巡回を完了したらDiscordに通知が来る。X(Twitter)への投稿もDiscordからワンコマンドである。
特に便利なのは添付ファイルのサポートである。スマホで撮った画像をDiscordに貼って「これを分析して」と指示できる。Botが添付ファイルをダウンロードしてエージェントに渡す仕組みを入れたことで、テキスト以外の入力もカバーできるようになった。
ターミナルだけの世界から、ポケットの中にエージェントがいる世界への移行である。自律エージェントの実用性は、インターフェースで大きく変わる。
まとめ
Discord Bot連携の実装で押さえるべきポイントは以下の通りである。
- MESSAGE CONTENT Intentを忘れない: Developer Portal + コード側の両方で有効にする
- Botとエージェントを疎結合にする: Botはリレーするだけでロジックはエージェント側に持たせる
- 非同期で応答する: Webhookを使って処理完了後に結果を返す
-
フォールバックを用意する: ランタイムが落ちていても
claude -pで最低限動くようにする - 2000文字制限を意識する: 長い応答は分割、ファイル読み取りは切り詰め
シリーズ一覧
| # | 内容 | 媒体 |
|---|---|---|
| ① | 自律AIエージェント構築の設計思想と実装 | Qiita(無料) |
| ② | CLAUDE.md設計 | Qiita(無料) |
| ③ | 記憶管理 MEMORY.md | Qiita(無料) |
| ④ | タスク管理 TASKS.md | Qiita(無料) |
| ⑤ | cron自動巡回 | Qiita(無料) |
| ⑥ | SOUL.md設計 | Qiita(無料) |
| ⑦ | サブエージェント設計 | Qiita(無料) |
| ⑧ | トークンコスト削減 | Qiita(無料) |
| ⑨ | CLAUDE.md設計ガイド(体系版) | Qiita(無料) |
| ⑩ | Discord Bot連携(本記事) | Qiita(無料) |
設計判断の裏側
本記事では技術的な実装手順を解説した。「なぜDiscordを選んだか」「他のインターフェース(Slack、LINE、Web UI)との比較検討」「運用で見えてきた課題と改善」といった設計判断の詳細は、noteシリーズで解説している。
リンク
- ai-agent-blueprint — 設計テンプレート一式(MIT License)
- @sentinel_dev93 — AIエージェント構築のリアルタイム共有
おわりに
この記事では、AIエージェントにDiscord Botを繋いでスマホから操作する方法を紹介しました。AIエージェントとDiscordを連携させている方がいたら、どんな使い方をしているかコメントで教えてください。
参考になったら いいね、後で見返すなら ストック していただけると励みになります。
他にもAIエージェント構築のノウハウを公開しています:
- 自律AIエージェント自作 — アーキテクチャ全体像