Notion を「タスクキュー」にして Claude Code を自動起動するアイデア
今回、初めて記事をAIに作らせたのでAI節全開でお楽しみください
TL;DR
- Notion のタスク DB のステータスを「着手可能」に変えると、ローカルの Claude Code が自動で走り出す仕組みを作った
- 外部 (VPS) は「Notion Webhook を受けて WebSocket でローカルに トリガーだけ 転送する」役に徹する
- Notion のトークンは ローカルにしか置かない
- 実データはローカルから Notion API を直接叩いて取りに行くので、VPS が乗っ取られても Notion 側は無傷
- Webhook の payload も一切データとして信用しない
- あくまで「何か更新があったよ」の合図としてしか使わない
Notion のステータスを 1 クリック変えるだけで実装が始まり、成果物が Notion にコメントで返る
コメントの記載を通知することでスマホでもプッシュ通知を受けとれる
モチベーション
「AI エージェントに実装を任せる」系の運用をしていると、だいたい次の 2 つが面倒になってくる
-
タスクの管理場所 と タスクを起動する場所 が別々にある
- Notion / Linear / Jira でタスクを書く
- 別の CLI やチャットにコピペしてエージェントを起動する
- 実行結果をまた Notion にコピペで戻す
-
エージェントを起動する場所 が「自分の PC」に固定される
- スマホから「あれやっといて」ができない
- 出先で思いついたタスクを積んでも、家に帰るまで走らない
やりたかったのはこれだけ
Notion のステータスを「着手可能」にしたら、家の PC で勝手にエージェントが動き始めて、終わったら Notion にコメントで結果を残しておいてほしい
「Notion をタスクキューにする」というシンプルな発想
全体構成
登場人物は 2 プロセスだけ
- VPS 側: Notion Webhook を受けて、WebSocket 接続してるローカルクライアントに「トリガー」を投げる常駐プロセス
- ローカル側: WebSocket でずっと接続しに行って、トリガーを受けたら Notion API に「着手可能なタスクある?」と聞きに行き、Claude Code をヘッドレスで起動する常駐プロセス
なぜ WebSocket なのか
「Notion Webhook → ローカル PC」を直接繋げないのは、ローカル PC を外部公開したくないから
よくあるパターンは以下の 3 択
| 方式 | メリット | デメリット |
|---|---|---|
| ローカルにポート開放 | 一番シンプル | 家の PC を公開する必要がある / IP 固定・DDNS・ファイアウォールが必要 |
| ポーリング (cron で数分おきに Notion に聞きに行く) | シンプル・安全 | 反応が遅い / API レートを消費する |
| VPS を挟んで WebSocket でローカルに push | 反応が即時 / ローカルは outbound だけで済む | VPS が要る |
3 番目を選んだ
ローカル PC は outbound の WebSocket 接続 を張るだけで、inbound を一切開けない
WebSocket の役割は「Notion で何か更新があった」という 1 ビットの通知 を運ぶだけ
中身は空でいい
// src/shared/protocol.ts より
export type ServerMessage = {
type: 'trigger';
receivedAt: string; // RFC 3339
};
これだけ
セキュリティの肝: payload を信用しない
ここが今回の設計の一番の勘所
素朴に作るとどうなるか
「Notion Webhook を受けて VPS からローカルへ WebSocket で流す」だけなら、Webhook の payload をそのままローカルに転送してもいい気がする
でもそれをやると
-
VPS が乗っ取られると Notion のデータが改ざんできる
- VPS 上に Notion トークンを置いておく必要が出てくる
- 攻撃者が VPS を取ったら、Notion の任意のページを書き換えられる
-
Webhook payload の改ざん耐性を上流に依存する
- Notion 側の署名を信じるしかない
- 中間で細工されたら、存在しないタスクを実行させられる可能性がある
今回の設計
VPS は Notion トークンを持たない
持たせない
- VPS 側の Webhook 受け口は、Notion からの署名 (HMAC-SHA256) を検証したら「trigger」というメッセージだけを WebSocket に流す
- ローカル側はトリガーを受けたら 自分から Notion API を叩いて 「着手可能」ステータスのタスクを取りに行く
- タスクの本文もリポジトリパスも、すべてローカルが Notion API から取得する
つまり VPS は「イベントが起きた」という事実だけを中継する 存在に徹する
❌ VPS: Notion トークン持ってて、payload 加工して流す
✅ VPS: トークン無し、trigger だけ流す
↓
ローカル: 自分で Notion API を叩いて真実を取りに行く
こうしておくと
- VPS が乗っ取られてもトークン漏洩ゼロ (そもそも置いてない)
-
偽の trigger を送られても実害なし
- 「トリガーが来た → Notion に着手可能タスクを聞きに行く → 何もなければ no-op」
- 攻撃者が VPS 経由で trigger を連打しても、Notion に着手可能タスクが無ければ何も起きない
- 「実行するタスク」は攻撃者が payload に埋め込めない (そもそもローカルが payload を見ない)
-
Notion API を叩く経路が 1 本に絞られる
- ローカル → Notion のみ
- ネットワーク境界が単純になって推論しやすい
Webhook を「データ配送路」ではなく「通知路」として扱うことで、外部を信じる範囲が最小化される、というのが今回の設計原則
実装での担保
// src/local/ws-client.ts のイメージ
ws.onmessage = () => {
// メッセージ本文は一切見ない。ただ trigger 到着の事実だけを使う
runFetchAndDispatch();
};
ws.onopen = () => {
// 接続復帰時にも一度走らせる (切断中に取りこぼした trigger のリカバリ)
runFetchAndDispatch();
};
「トリガー受信時にやること」は毎回こう定義する
- Notion API に「ステータス == 着手可能」で問い合わせる
- 該当タスクが無ければ即 return (no-op)
- あればステータスを「進行中」にロックしてから Claude Code を起動する
冪等 (idempotent) なので、trigger が二重に来ても、切断中に取りこぼしても、次の trigger や再接続時のフックで自然に回収される
「着手可能 → 進行中 → レビュー待ち」というフロー
Notion 側のステータスは 5 段階
未着手 → 着手可能 → 進行中 → レビュー待ち → 完了
- 人間が「着手可能」に落とすとエージェントが起動する
- エージェントは実行前に「進行中」にロックする (同時多重実行の防止)
- 成功したら「レビュー待ち」に自動遷移し、実行時間などをメタコメントに書き残す
- 失敗したら「進行中」のまま失敗コメントを残す / エージェントは絶対に「完了」にしない
- 「完了」にするのは人間のレビューだけ — これはあえて守るルール
「進行中」のまま 24 時間以上放置されたタスクには、watchdog が 1 時間おきに走って自分の Notion アカウントに @メンション付きコメントを残す
Notion のネイティブ通知 (メール・モバイル push) に乗るので、追加のインフラは不要
Claude Code の起動と、渡す情報を最小化する
Notion のタスクを Claude Code に食わせるとき、渡すのは Notion タスクページの URL だけ にした
タスクの本文・添付・関連するプロジェクト情報は、Claude Code 側が Notion MCP 経由で自分で取りに行く設計にしている
こうする理由
- 起動時に snapshot を取らない ので、実行が始まった瞬間の最新のタスク本文がエージェントに渡る
-
URL 以外に渡すべきものが無い ので、コマンドラインが常に固定形状になる
- タスクごとに変わるのは URL 部分だけ
- シェルを介さず argv 配列で起動すれば、コマンドインジェクションの余地が自然に消える
-
リポジトリパスは Notion のプロジェクト DB から解決する
- タスクは「プロジェクト」に紐付いていて、プロジェクト側に「リポジトリパス」を持たせている
- Claude Code は該当ディレクトリを cwd にして起動される
- ローカルに「タスク → ディレクトリ」のマッピングを持たない (Notion が SSOT)
Notion のトークンをローカルにしか置かない、という原則ともきれいに繋がる
Claude Code もローカルのトークン経由で Notion を読み書きする
良かったこと・想定外だったこと
良かったこと
- Notion のモバイルアプリからタスクを積めるようになった / 外出先で思いついたことを Notion に書いてステータスを「着手可能」にすれば、家の PC で勝手に走ってる
- 切断・再起動に強い / VPS 再起動しても、ローカルデーモン落ちてても、次の接続時に「着手可能」を全部拾いに行く
- Notion 側にログが集約される / 実行結果・失敗理由・所要時間が全部 Notion のコメントとして残る
想定外だったこと
- Notion Webhook のサブスクリプション登録が地味に癖がある / 初回登録時に Notion 側が verification token を送ってきて、それを UI に貼り戻すフローがある / VPS 側は「トークン未設定の間だけ署名検証をスキップして verification token をログに吐く」ブートストラップモードを用意しないと詰む
- タスクの重複起動防止に「ステータス更新をロック」として使う 発想は最初思いつかなかった / DB を持たない設計にしたので、Notion のフィールドをそのままロックの実体として使う / 実行前に「進行中」に落とせなければ他が実行中とみなす
まとめ
「外部の Webhook をローカルに繋ぐ」系の設計をやるとき、payload をどこまで信用するかが本当に効いてくる
- VPS を通す / ただし VPS には秘密を置かない
- WebSocket でトリガーだけ運ぶ / データは常にローカルから API を叩いて取りに行く
- 冪等な fetch-and-dispatch を、接続確立時と trigger 受信時に毎回走らせる
この 3 つのルールを守ると、外部を信じる範囲が最小化されて、切断リカバリもタダで手に入る
Notion をタスクキューにする以外にも、汎用の「外部イベント → ローカル実行」パターンとしてかなり応用が効くはず
それでは良いClaude Codeライフを