サステナビリティアプリのために自作のイベントバスを作った—OpenClawを使ったエージェント自動化で学んだこと

Dev.to / 2026/4/22

💬 オピニオンDeveloper Stack & InfrastructureIdeas & Deep AnalysisTools & Practical UsageModels & Research

要点

  • このプロジェクト「PlanetLedger」は、銀行明細(CSV/PDF)をアップロードして、取引のカテゴリ分け・“惑星インパクトスコア”・その後の自動ワークフローを、イベントバス「OpenClaw」を通じて実行するサステナビリティ家計ダッシュボードです。
  • 主要なフローは、ユーザーをAuth0へ通し、エージェントがOpenClawのイベント駆動パイプラインを起動して、AIによる洞察と通知を生成し、それをUIに表示する形になっています。
  • PlanetLedgerは、LLMベースの分析と並行して「決定的で説明可能」なカテゴリ分け・スコアリング規則を重視し、RAG(実データに基づくコンテキスト構築)でユーザーの実支出データにプロンプトを根付かせます。
  • 技術スタックは、Next.js 15.5(App Router + TypeScript)、Auth0 v4、LangChain(OpenAI gpt-4o-mini)とGeminiのフォールバック、明細抽出のpdf-parse、そして永続的なエージェントメモリなどで構成されています。
  • 著者の主な学びは、取り込み後にサステナビリティ系の多段ワークフローをオーケストレーションするための“自作のプロセス内イベントパイプライン”をエージェント自動化に適用する実践知です。

これは OpenClaw Challenge の提出です。

作ったもの

PlanetLedger は、銀行の明細書を環境インテリジェンスに変えるサステナブル・ファイナンスのダッシュボードです。CSV または PDF の明細書をアップロードすると、アプリがすべての取引を自動でカテゴリ分けし、惑星への影響スコアを計算し、さらに一連の自動化されたワークフローを起動します。これらは、私が OpenClaw と呼んだイベントバスによってすべて実現しています。

発想はシンプルです。多くの人は、日々の支出が環境的にひどいのかどうか、そもそも分かっていません。PlanetLedger は、スプレッドシートなしで、その状態を 10 秒で可視化します。

ライブデモ — ログイン不要

PlanetLedger

PlanetLedger は、エージェントを主軸にしたサステナブル・ファイナンスのダッシュボードで、銀行の明細書を環境インテリジェンスに変換します。CSV または PDF をアップロードすると、パーソナライズされたインパクトスコア、AI による洞察、適応型のおすすめが得られます。これは、プロセス内のイベントパイプライン、Auth0(FGA 付き)、そして永続的なエージェントメモリシステムによって支えられています。

フロー: User → Auth0 → PlanetLedger Agent → OpenClaw Pipeline → Insights + Notifications → UI

技術スタック

レイヤー 技術
フレームワーク Next.js 15.5 App Router + TypeScript
認証 Auth0 v4 (@auth0/nextjs-auth0) — middleware ベース、ルートハンドラ不要
AI / LLM LangChain + OpenAI gpt-4o-mini / Google Gemini gemini-1.5-flash(フォールバック)
ルールエンジン カスタムのカテゴリ分け + スコアリング — 決定論的で説明可能
RAG 実際の支出データに基づいてプロンプトへ文脈を構築
PDF パース pdf-parse v2 + AU の銀行明細書レグエックス抽出器
イベントバス OpenClaw — 独自に作った、プロセス内のイベント駆動型ワークフロー実行基盤
Cron Vercel Cron (vercel.json) — 毎週月曜 09:00 UTC にレポートを送信
スタイリング Tailwind CSS,

OpenClaw の使い方

OpenClaw は、PlanetLedger に組み込んだイベント駆動の自動化レイヤーです。意図的に軽量にしています。外部のキューもありませんし、インフラもありません。単に、型付きのイベントバスで「何かが起きた」と「それに対して何をするか」を疎結合にしているだけです。

Openclaw 図

コアとなる設計

仕組み全体は、4 つのファイルに渡って合計およそ 60 行です。

lib/openclaw/
  types.ts      ← イベント + トリガーの型
  registry.ts   ← トリガーを登録し、イベントを発火
  trigger.ts    ← API ルートから呼び出される
  workflows.ts  ← 実際の自動化ロジック

— すべてを型付けして、ワークフローが受け取るデータを正確に把握できるようにしています。

export type OpenClawEventType =
  | "transactions_uploaded"
  | "score_calculated"
  | "insights_generated"
  | "score_improved"
  | "weekly_report"
  | "high_impact_detected"
  | "behavioral_pattern_detected"
  | "custom";

export interface OpenClawEvent {
  type: OpenClawEventType;
  userId: string;
  payload?: any;
  timestamp?: string;
}

export type OpenClawTrigger = (event: OpenClawEvent) => Promise<void>;

レジストリ — イベントタイプごとに、トリガー関数の配列を持つシンプルなマップです。

const triggers: Record<string, OpenClawTrigger[]> = {};
export function registerOpenClawTrigger(eventType: string, trigger: OpenClawTrigger) {
  if (!triggers[eventType]) triggers[eventType] = [];
  triggers[eventType].push(trigger);
}

export async function fireOpenClawEvent(event: OpenClawEvent) {
  const list = triggers[event.type] || [];
  for (const trigger of list) {
    await trigger(event);
  }
}

登録はモジュールの読み込み時に行われます。つまり、レジストリファイルが import して、4つのワークフローすべてを接続します:

registerOpenClawTrigger("transactions_uploaded", autoInsightOnUpload);
registerOpenClawTrigger("transactions_uploaded", highImpactAlert);
registerOpenClawTrigger("weekly_report", weeklyReport);
registerOpenClawTrigger("score_improved", scoreImprovedCelebration);

イベントの発火は、API ルートからの呼び出しが 1 回でパイプライン全体をつなぐようになりました:

// app/api/upload/route.ts — パースして取引を保存した後:
await openClawChainedTrigger(session.user, previousScore);

openClawChainedTrigger は、4つのイベントを順番に発火します:transactions_uploaded → score_calculated → insights_generated → score_improved(スコアが実際に増加した場合のみ)。アップロードルートは解析済みデータをすぐにクライアントへ返します——レスポンスが送られ始めた時点で、チェーン全体が走り出します。

4つのワークフロー

ワークフロー1 — autoInsightOnUpload

すべての transactions_uploaded イベントで発火します。保存されたばかりの取引を取得し、buildRagContext() を実行して、最も関連性の高い支出シグナルをまとめます(支出が多い上位カテゴリ、上位加盟店、最近のパターン)。その後 buildAgentInsights() を呼び出して、パーソナライズされた提案を生成します。生成結果はキャッシュされるため、ユーザーがダッシュボードに移動するとインサイトパネルが即座に表示されます。

export async function autoInsightOnUpload(event: OpenClawEvent) {
  const transactions = getTransactions(event.userId);
  const score = getScore(event.userId);
  if (!transactions.length || !score) return;

  const ragContext = buildRagContext(transactions, score);
  const insights = buildAgentInsights(
    transactions,
    event.payload?.userContext,
    score,
    ragContext
  );
  setCachedInsights(event.userId, insights);
}

RAGコンテキストの構築がこの仕組みを面白くしています——すべての取引をインサイトエンジンに送るのではなく、まずは最も有用なシグナルに蒸留します:

  • 直近7日間の取引(過去1週間に何もなければすべて)
  • 総支出が多い上位2カテゴリ
  • 検出された行動パターン(例:「今週はフードデリバリーの注文が3件以上」)

加盟店名は意図的にRAGコンテキストから除外されています。加盟店名はPIIに近い情報であり、インサイトエンジンが役に立つ提案を作るのに必須ではないためです。

この“根拠付け”によって、インサイトが一般的ではなく、その人に合ったものに感じられます。

ワークフロー2 — highImpactAlert

これも transactions_uploaded で発火します。インパクトスコアが低いかどうかを確認し、低い場合はユーザーのアプリ内通知ベルに対して、構造化された通知を直接プッシュします:

返却形式: {"translated": "翻訳されたHTML"}
export async function highImpactAlert(event: OpenClawEvent) {
  const score = getScore(pseudonymize(event.userId));
  if (!score) return;

  if (score.impactScore < 40) {
    pushNotification(event.userId, {
      type: "high_impact",
      title: "High-Impact Alert",
      body: `${score.highImpactCount} high-impact transactions detected this week (score: ${score.impactScore}/100).`,
    });
  }
}

通知はダッシュボードのベルに即座に届きます――メールも外部サービスもインフラも不要です。pseudonymize() は、ユーザーIDを FNV-1a ハッシュ(usr_XXXXXXXX)で包み、ログやストレアに触れる前に処理します。これにより、PII(個人を特定できる情報)が構造化された出力として漏えいすることはありません。

ワークフロー 3 — weeklyReport

weekly_report イベントで発火します。Vercel Cron により毎週月曜 09:00 UTC に POST /api/cron/weekly-report を通じてトリガーされます。全体のダイジェストを生成し、通知ベルへプッシュします:

export async function weeklyReport(event: OpenClawEvent) {
  const transactions = getTransactions(event.userId);
  const score = getScore(event.userId);
  const insights = getCachedInsights(event.userId) ?? buildAgentInsights(...);

  pushNotification(event.userId, {
    type: "weekly_report",
    title: "Weekly Report",
    body: `Score: ${score.impactScore}/100 · Spend: $${totalSpend.toFixed(0)} · Trend: ${score.weeklyTrend}`,
  });
}

vercel.json の cron 設定:

{ "crons": [{ "path": "/api/cron/weekly-report", "schedule": "0 9 * * 1" }] }

エンドポイントは Bearer CRON_SECRET ヘッダーを検証し、環境変数の CRON_USER_IDS を反復処理して、ユーザーごとに weekly_report イベントを発火させます。

ワークフロー 4 — scoreImprovedCelebration

score_improved イベントで発火します。openClawChainedTrigger は、新しいスコアがアップロード前のスコアより高い場合にこれを発火します。お祝い通知をプッシュします:

export async function scoreImprovedCelebration(event: OpenClawEvent) {
  pushNotification(event.userId, {
    type: "score_improved",
    title: "Score Improved ",
    body: `Your eco score improved to ${event.payload?.newScore}/100 — great progress!`,
  });
}

これはフィードバックループを閉じるワークフローです。アップロード → パイプライン実行 → スコアが改善 → ベルが点灯。すべてインプロセスで完結し、往復(ラウンドトリップ)はありません。

同じイベントに対する複数のワークフロー + 連結(チェained)パイプライン

レジストリ方式が気に入っている点の1つは、同じイベント種別に対して複数のトリガーを登録でき、それらがすべて順番に(シーケンシャルに)発火することです。autoInsightOnUploadhighImpactAlert はどちらも transactions_uploaded で発火します:

registerOpenClawTrigger("transactions_uploaded", autoInsightOnUpload);
registerOpenClawTrigger("transactions_uploaded", highImpactAlert);

score_improvedscoreImprovedCelebration を追加したのも、もう1回 registerOpenClawTrigger を呼び出しただけです。アップロードのロジックには変更なし。既存のワークフローにも変更なし。

連結(チェーンされた)パイプラインはここからさらに一歩進みます。1つのイベントを発火して、下流のワークフローがそれを拾ってくれることに賭けるのではなく、openClawChainedTrigger が意図したシーケンスを発火します:

transactions_uploaded
  → score_calculated   (新しいスコアを保存する)
  → insights_generated (autoInsightOnUpload をトリガーする)
  → score_improved     (スコアが増えた場合のみ — セレブレーションをトリガーする)

チェーンの各ステップは payload を通じてコンテキストを次へ渡すため、後続のワークフローは前のステップが何を生成したかを正確に把握できます。インフラなしで実現する、軽量なサーガ(saga)パターンです。

デモ

planet-ledger.vercel.app — 自分のAU銀行明細をアップロードするためにサインイン

planet-ledger.vercel.app/demo — ログイン不要、今すぐサンプルデータで試してみてください

デモページでは、フルのダッシュボードが表示されます。サマリーカード、取引テーブル、インサイト(洞察)パネル、週次サマリー、デモ用の通知フィード、そしてAIチャット、What-If Simulator、メモリタイムラインのロックされたプレビューです。サインイン後、明細をアップロードすると、4つすべてのOpenClawワークフローが自動的にトリガーされます。

PlanetLedger

PlanetLedger は、エージェントを起点にしたサステナビリティ・ファイナンスのダッシュボードで、銀行明細を環境インテリジェンスへ変換します。CSV または PDF をアップロードすると、パーソナライズされたインパクトスコア、AI を活用したインサイト、そして適応型のレコメンデーションが得られます。これはインプロセスのイベントパイプライン、Auth0 + FGA、そして永続的なエージェントメモリシステムによって実現しています。

フロー: User → Auth0 → PlanetLedger Agent → OpenClaw Pipeline → Insights + Notifications → UI

Stack














































レイヤー 技術
フレームワーク Next.js 15.5 App Router + TypeScript
認証
Auth0 v4 (@auth0/nextjs-auth0) — ミドルウェアベース、ルートハンドラなし
AI / LLM LangChain + OpenAI gpt-4o-mini / Google Gemini gemini-1.5-flash(フォールバック)
ルールエンジン カスタムの分類 + スコアリング — 決定論的で説明可能
RAG 実際の支出データに基づいてプロンプトを裏付ける(ground)コンテキストビルダー
PDF パース
pdf-parse v2 + AU 銀行明細の正規表現(regex)抽出器
イベントバス
OpenClaw — 自作のインプロセス、イベント駆動型ワークフロールンナー
Cron Vercel Cron (vercel.json) — 毎週月曜 09:00 UTC の週次レポート
スタイリング Tailwind CSS,





学んだこと

分離(デカップリング)には、追加のファイル分だけの価値がある。 アップロードAPIルートは、インサイト、アラート、レポートについて何も知りません。パースして保存し、連結トリガーを1つ発火して返すだけです。この分離のおかげで、改善の反復がかなり簡単になりました。インサイトの生成方法を変えたいと思っても、アップロードのロジックに一切触れずに済んだのです。

イベントにはうまく名前を付ける。 transactions_uploaded は明確です。custom は罠です。結局それを何にでも使うことになり、フィルタする能力を失います。score_calculatedinsights_generatedscore_improved を第一級のイベント種別として追加したことで、連結パイプラインが読みやすくなり、デバッグもしやすくなりました。

構造化されたペイロード > 文字列。 初期のアラート用ワークフローは、単に文字列メッセージをログに出すだけでした。typeuserIdimpactScorehighImpactCounttimestamp を持つ構造化オブジェクトに切り替え、pushNotification() にルーティングすることで、出力をUIですぐに使えるようになりました。再フォーマットは不要です。

ログの前に仮名化(pseudonymize)。 生のユーザーIDを構造化ログに渡すのは、規模が大きくなるほど問題を引き起こす習慣です。すべての userIdpseudonymize()(FNV-1a → usr_XXXXXXXX)で包み、ログやストアに触れる前に処理するのは、一行で直せる改善です。後から付け足すより、最初から行うほうがずっと簡単です。

インプロセスのイベントバスは、初期段階のアプリでは過小評価されがち。 RabbitMQ ではありません。サーバーの再起動では生き残らず、イベントをリプレイすることもできません。でも、きちんとしたキューから得られるのと同じワークフロー分離のパターンを、インフラコストゼロで提供してくれました。スケールする時期になったら、移行手順は明確です:fireOpenClawEvent をエンキュー呼び出しに置き換え、ワークフロ―関数をワーカーへ移します。

OpenClaw を拡張ポイントとして使う。 まだUIに完全には配線していないイベント種別(behavioral_pattern_detected)も、そこに待機しています。将来のワークフロー――目標達成の進捗更新、ピア比較ベンチマーク、能動的なナッジ――などはすべて、アプリのルートに変更を加えることなく workflows.ts に追加できます。

ClawCon Michigan

返却形式: {"translated": "翻訳されたHTML"}

今回はClawCon Michiganには参加しませんでしたが、来年はぜひ参加したいです。特にAIエージェントのシステムにおけるイベント駆動型パターンについて話し合いたいです。OpenClawの設計上の多くの決定(型付きイベント、逐次トリガー実行、構造化されたアラートのペイロード)は、エージェントのワークフローが従来のジョブキューとどう違うのかを考えることから生まれました。そして、それはまさに対面のイベントでこそ盛り上がりそうな種類の会話だと思います。