自分で動かしている AI アシスタント OpenClaw(オープンクロー)の「おぷー」を、Discord のたくさんのスレで使っています。困るのが、OpenClaw の再起動や時間切れで返事が途中で切れたまま放置されることです。スレが多いので、切れたスレはすぐ埋もれて見落とします。
そこで、切れた会話を15分おきに探して、見つけたら続きから答え直させる見張りスクリプトを入れました。普段は LLM を使わないので、トークンは消費しません。モデルが動くのは、切れた会話を見つけたときだけです。

返事はこうやって切れる
OpenClaw は、設定を反映したり版を更新したりするときに、ゲートウェイ(Discord とモデルの間をつなぐ常駐プログラム)を再起動します。その最中に考えていた返事は、そこで止まります。モデルの呼び出しが時間切れになったときも同じです。
実際にあった例です。2026年10月15日の朝、おぷーに質問して作業が始まった直後にゲートウェイが再起動され、返事が時間切れのエラーだけで止まりました。

このあと、このスレは止まったままでした。このときはまだ見張りが無く、別のスレで「再起動された?」と聞いて初めて気づきました。おぷーが下で説明するのと同じ方法(openclaw agent で続きを頼む)を手で実行して、16分後にようやく答えが返ってきました。

このとき返事が切れたスレは2つありました。もう1つは、次の決まり文句だけで止まっていました。

さらに、再起動の最中に送った「再起動された?」という発言そのものも処理されずに消えていました。切れ方は3通りあります。
| 切れ方 | スレに残るもの | 起きる場面 |
|---|---|---|
| エラーの決まり文句だけ | 「⚠️ … request failed (request timed out, HTTP 408)」「⚠️ Something went wrong while processing your request」 | モデルの呼び出しの時間切れ、再起動で作業が打ち切られたとき |
| 作業中の表示のまま | 「🛠️ Bash」のような道具を使っている途中の表示 | 作業の途中で再起動されたとき |
| 何も返ってこない | 自分の発言だけ | 再起動の最中に発言が届いたとき |
見張りは OpenClaw の外に置く(トークンを使わないため)
見張りは、OpenClaw の機能ではなく、マシンの cron から動かす Node.js のスクリプト1本にしました。理由は2つです。
- トークンを使わない:OpenClaw の heartbeat(定期的に様子を見る機能)は、そのたびにエージェント、つまりモデルを動かします。既定で30分おきです[3]。15分おきに AI に「切れたスレはある?」と聞くと、何も無くても毎回トークンを使います。見張りの判定は文字列を比べるだけで済むので、LLM はいりません
- 見張る相手と一緒に止まらない:OpenClaw の定期実行(automations)にも、モデルを呼ばずにコマンドだけを走らせる種類があります[2]。ただ、これはゲートウェイの中で動くので、ゲートウェイが再起動している間は一緒に止まります。切れる原因の外に置いておくほうが安心です
スクリプトがやるのは、データベースと Discord を読んで、条件に当てはまるか比べることだけです。モデルが動くのは、切れたスレを見つけて openclaw agent を起動したときだけです。
何を見て「切れた」と判定するか
まず、OpenClaw のセッション DB(SQLite の session_nodes 表)から、直近6時間に動いた Discord のセッションを拾います。セッションの名前の末尾がスレの ID なので、Discord の API でそのスレの新しい発言を15件読みます[4]。DB は Node.js に入っている node:sqlite で読み取り専用で開きます[5]。
そのうえで、自分の最後の発言のあとに次のどれかになっていたら「切れた」とみなします。
| 条件 | 見るもの | 待つ時間 |
|---|---|---|
| 呼ばれたのに返事が無い | 自分の発言が bot へのメンションか、bot の発言への返信なのに、bot の発言が1件も無い | 10分 |
| エラーで終わった | bot の発言が、⚠️ で始まる行や request failed、timed out、Something went wrong、Agent run failed などの決まり文句だけ | 待たない |
| 作業中の表示のまま | bot の最後の発言が 🛠️ の作業中の表示で、ふつうの返事が無い | 20分 |
ただし、DB で状態が running(いま処理中)のセッションには触りません。長い作業をしているだけかもしれないからです。同じ発言を何度も拾わないように、処理した発言の ID を状態ファイルに2日間残します。
見つけたら openclaw agent で続きを頼む
再開には、OpenClaw のコマンド openclaw agent を使います。スクリプトや cron から1回だけエージェントを動かすためのコマンドです[1]。
openclaw agent \
--session-key agent:main:discord:channel:<スレの ID> \
--message "このスレの発言「…」への返事が途中で切れています。続きをやって答えてください" \
--deliver --reply-channel discord --reply-to channel:<スレの ID>
--session-keyに切れたスレのセッションを渡すと、そのスレの会話の続きとして動きます。それまでのやり取りを覚えたまま答え直せます--deliverと--reply-channel、--reply-toで、返事をそのスレに書き込ませます- 頼む文には「最初に『途中で切れていた、ごめん』と一言添える」「もう答え終わっているなら、そう一言だけ書く」と入れています。二重に答えるのを防ぐためです
再開させたら、管理用のスレに「再開させた」とスレへのリンクを出します。どのスレが切れていたかを、あとから自分で見に行けるようにするためです。実際に見張りが出したお知らせです。

スクリプト全体
実際に動かしているものから、ID やパスを環境変数に出した版です。Node.js 24 で動かしています。Discord のトークンは OpenClaw の設定ファイル(openclaw.json)から読みます。
スクリプト全体を開く(約100行)
#!/usr/bin/env node
// OpenClaw の返事が途中で切れた Discord スレを探して、続きから再開させる(cron から15分おき)
// 普段は LLM を使わない。見つけたときだけ `openclaw agent --deliver` で続きを頼む
// 使い方: node stalled-replies.mjs [--dry-run]
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { spawn, execFileSync } from 'node:child_process';
import { homedir } from 'node:os';
const DRY = process.argv.includes('--dry-run');
// プロファイル名(~/.openclaw-<名前> を使っている場合だけ)
const PROFILE = process.env.OPENCLAW_PROFILE;
const OC_HOME = `${homedir()}/.openclaw${PROFILE ? `-${PROFILE}` : ''}`;
// cron は PATH が短いのでフルパスを渡すとよい
const OPENCLAW = process.env.OPENCLAW_BIN || 'openclaw';
// OpenClaw の bot と、話しかける自分の Discord ユーザー ID
const BOT_ID = process.env.BOT_ID;
const OWNER_ID = process.env.OWNER_ID;
// 「再開させた」を書く管理用のチャンネル
const REPORT_TO = process.env.REPORT_CHANNEL_ID;
const STATE = `${OC_HOME}/stalled-replies-state.json`;
// これより古い話は拾わない(時間)
const LOOKBACK_H = 6;
// 呼ばれてから返事が無いまま何分で「切れた」とみなすか
const NO_REPLY_MIN = 10;
// 作業中の表示(🛠️)のまま何分止まっていたら「切れた」とみなすか
const PROGRESS_MIN = 20;
// OpenClaw の失敗・時間切れの決まり文句
const ERROR_RE = /^⚠️|request failed|timed out|Something went wrong|did not produce a visible reply|Agent (run )?failed/i;
const cfg = JSON.parse(readFileSync(`${OC_HOME}/openclaw.json`, 'utf8'));
const dc = cfg.channels.discord;
const TOKEN = dc.token || Object.values(dc.accounts || {})[0]?.token;
const state = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : { handled: {} };
const now = Date.now();
const profileArgs = PROFILE ? ['--profile', PROFILE] : [];
// 直近に動いた Discord のセッション(= 話しかけたスレ)
const db = new DatabaseSync(`${OC_HOME}/agents/main/agent/openclaw-agent.sqlite`, { readOnly: true });
const sessions = db.prepare(
`select session_key, status from session_nodes
where session_key like 'agent:main:discord:channel:%' and coalesce(last_interaction_at,0) > ?`
).all(now - LOOKBACK_H * 3600e3);
const api = (path) => fetch(`https://discord.com/api/v10${path}`, { headers: { Authorization: `Bot ${TOKEN}` } });
const messages = async (ch) => { const r = await api(`/channels/${ch}/messages?limit=15`); return r.ok ? r.json() : []; };
const ts = (m) => Date.parse(m.timestamp);
const ageMin = (m) => (now - ts(m)) / 60e3;
const isBot = (m) => m.author?.id === BOT_ID;
const addressesBot = (m) => m.content?.includes(`<@${BOT_ID}>`) || m.referenced_message?.author?.id === BOT_ID;
const found = [];
for (const s of sessions) {
// いま動いているものは触らない
if (s.status === 'running') continue;
const ch = s.session_key.split(':').pop();
// 新しい順に並んでいる
const msgs = (await messages(ch)).filter((m) => ageMin(m) < LOOKBACK_H * 60);
const lastOwner = msgs.find((m) => m.author?.id === OWNER_ID);
if (!lastOwner || state.handled[lastOwner.id]) continue;
const botAfter = msgs.filter((m) => isBot(m) && ts(m) > ts(lastOwner));
let reason = null;
if (!botAfter.length) {
if (addressesBot(lastOwner) && ageMin(lastOwner) >= NO_REPLY_MIN) reason = '呼ばれたのに返事が無い';
} else {
const latest = botAfter[0];
const finals = botAfter.filter((m) => !ERROR_RE.test(m.content) && !m.content.includes('🛠️'));
if (!finals.length && ERROR_RE.test(latest.content)) reason = '返事がエラーで終わった';
else if (!finals.length && latest.content.includes('🛠️') && ageMin(latest) >= PROGRESS_MIN) reason = '作業中の表示のまま止まった';
}
if (reason) {
const info = await (await api(`/channels/${ch}`)).json();
found.push({ ch, guild: info.guild_id, msg: lastOwner, reason, key: s.session_key });
}
}
for (const f of found) {
const quote = f.msg.content.replace(/\s+/g, ' ').slice(0, 200);
const note = `【再開のお願い(見張りスクリプトから)】このスレの発言「${quote}」への返事が途中で切れています(${f.reason})。` +
'最初に「途中で切れていた、ごめん」と一言添えて、続きをやって答えてください。すでに答え終わっているなら、その旨を一言だけ書いてください。';
console.log(`${new Date().toISOString()} stalled ${f.ch} ${f.msg.id} ${f.reason}`);
if (DRY) continue;
spawn(OPENCLAW, [...profileArgs, 'agent', '--session-key', f.key, '--message', note,
'--deliver', '--reply-channel', 'discord', '--reply-to', `channel:${f.ch}`, '--timeout', '1200'],
{ detached: true, stdio: 'ignore' }).unref();
state.handled[f.msg.id] = now;
}
if (found.length && !DRY && REPORT_TO) {
const links = found.map((f) => `- <https://discord.com/channels/${f.guild}/${f.ch}/${f.msg.id}>(${f.reason})`).join('\n');
execFileSync(OPENCLAW, [...profileArgs, 'message', 'send', '--channel', 'discord',
'--target', `channel:${REPORT_TO}`, '--message', `途中で切れていたスレを見つけたので、続きから再開させた。\n${links}`],
{ stdio: 'ignore', timeout: 90e3 });
}
// 古い記録は捨てる
for (const [id, t] of Object.entries(state.handled)) if (now - t > 2 * 86400e3) delete state.handled[id];
if (!DRY) writeFileSync(STATE, JSON.stringify(state));
else console.log(`checked ${sessions.length} sessions, found ${found.length}`);
環境変数は次の4つです。
| 環境変数 | 入れるもの |
|---|---|
BOT_ID | OpenClaw の bot の Discord ユーザー ID |
OWNER_ID | bot に話しかける自分の Discord ユーザー ID |
REPORT_CHANNEL_ID | 「再開させた」を書く管理用のチャンネルの ID(無ければ知らせない) |
OPENCLAW_PROFILE | プロファイルを使っている場合だけ、その名前(~/.openclaw-<名前> を読む) |
試して、cron に入れる
--dry-run を付けると、見つけたものを表示するだけで、再開も報告もしません。まずこれで、切れていないのに拾うことがないかを確かめます。

最初に試したときは、10月15日 09:20 の状態(切れたスレが2つある状態)を再現して走らせ、2つとも見つけました。過去72時間分を流しても、切れていないのに拾ったものは0件でした。
問題が無ければ crontab に1行足します。
*/15 * * * * /path/to/node --no-warnings /path/to/stalled-replies.mjs >> ~/.cache/stalled-replies.log 2>&1
cron で動かすときの注意が2つあります。
- node と openclaw はフルパスで書く:cron の PATH は短いので、nvm などで入れた node は見つかりません。スクリプトの中の openclaw も、
OPENCLAW_BINでフルパスを渡してください - cron の中では
systemctl --userが使えないことがある:うちの版は、OpenClaw の場所を systemd のユニットから読もうとして、cron からだと「Failed to connect to bus」で止まっていました(この記事を書いていてログを見て気づき、直しました)。手で走らせると動くので気づきにくいところです。ログを一度は見て、エラーが出ていないか確かめてください
拾えないもの
- Discord 以外:LINE など、ほかのチャンネルは見ていない
- 呼びかけていない発言:再起動の最中に消えた発言は、メンションか返信で bot を呼んでいたものしか拾えない。スレの中でただ書いただけの発言は、bot が返事をしなくても普通のことなので区別できない
- 文言が変わったとき:エラーの判定は OpenClaw の決まり文句に頼っている。OpenClaw の版が上がって文言が変わると拾えなくなる
まとめ
- OpenClaw は、再起動やモデルの時間切れで返事が途中で切れることがある。スレが多いと埋もれて気づかない
- cron で15分おきに、セッション DB と Discord のスレを読むだけのスクリプトを動かす。普段は LLM を使わないので、トークンは消費しない
- 切れたスレを見つけたときだけ
openclaw agent --session-key … --deliverで、そのスレの会話の続きとして答え直させる
参考
- [1] OpenClaw docs「Agent」(openclaw agent コマンド)— –session-key、–deliver、–reply-channel、–reply-to リンク
- [2] OpenClaw docs「Automation payloads」— モデルを呼ばない Command など、ジョブの種類 リンク
- [3] OpenClaw docs「Heartbeat」— 既定で30分(認証方式によって1時間)ごとにエージェントを動かす リンク
- [4] Discord Developer Docs「Message」— Get Channel Messages リンク
- [5] Node.js Docs「SQLite」(node:sqlite) リンク

コメント