やまびこ (yamabiko) 仕様書 — Slack ハドル代替
ステータス: MVP 実装済み (初回デプロイ未実施)。 コードネーム「やまびこ」およびコマンド名 /yamabiko は仮称です。
Slack のハドル (Huddle) を Cloudflare Realtime (RealtimeKit) で代替する社内向け通話機能の正本仕様書です。apps/yamabiko-slack / apps/yamabiko-app / packages/yamabiko-core / packages/auth を改修する際は、先にこのドキュメントを読み、仕様変更を伴う場合は同じ PR で更新してください。
背景と目的
- Slack ハドルの代替となる「チャンネルから 1 クリックで始める気軽な通話」を自前のスタック (Cloudflare Workers + RealtimeKit) で提供する
- ハドルとの差別化として、会話を揮発させない: 終了後に録音を AI が直接理解し、まとめをチャンネルのスレッドに残す
- WebRTC の難所 (SFU、帯域適応、デバイス管理) は Cloudflare RealtimeKit (旧 Dyte) に委譲し、自前実装しない
決定事項 (機能定義の合意)
| 論点 | 決定 |
|---|---|
| MVP のメディア | 音声 + 画面共有 (ビデオはフェーズ 2)。RealtimeKit の preset で制御する |
| 開始の入口 | スラッシュコマンド (/yamabiko → チャンネルに参加リンクを投稿) |
| 参加者認証 | better-auth。このアプリ専用ではなく、他サービスでも共用する認証基盤 (packages/auth + 共有 D1 tied-auth) として新設 |
| 記録機能 | 録音 → Cloudflare Workflow → Gemini の音声・動画理解で直接まとめ → スレッド投稿 を MVP に含める。書き起こしは介さない (transcription 機能は使わない) |
アプリ全体像
| 名称 | 種別 | 役割 |
|---|---|---|
packages/yamabiko-core | 共有コア (framework 非依存 TS パッケージ) | クリーンアーキテクチャ層 (domain / application / infrastructure / composition) を所有し exports で公開。両 Worker が依存する |
packages/auth (@tied-workspace/auth) | 共有認証基盤 | better-auth の設定。drizzle スキーマと migrations は packages/d1 で統一管理し (@tied-workspace/d1/schema/tied-auth を import)、migrations は packages/d1/migrations/tied-auth/。やまびこ専用ではなく、ワークスペースの他サービスも同じ DB (ユーザー / セッション) を共用する前提 |
yamabiko-slack (apps/yamabiko-slack) | 素の Cloudflare Worker (公開) | /slack/commands (スラッシュコマンド) と /realtimekit/webhooks (参加/退出/終了) を受ける。署名検証 → 即 200 → ctx.waitUntil。録音要約 Workflow yamabiko-postcall もこの Worker が持つ |
yamabiko-web (apps/yamabiko-app) | React Router v7 (framework mode / Cloudflare Workers) | 通話ルーム UI。loader が better-auth セッションを検証し、RealtimeKit の Add Participant で authToken を取得。クライアントは RealtimeKit React UI Kit (RtkMeeting) を描画する。公開 Worker だが better-auth で保護 |
| Slack App | — | スラッシュコマンドを yamabiko-slack に POST する。Sign in with Slack (OIDC) のクレデンシャルも同じ App で発行する |
| RealtimeKit | — | Meeting / Participant / preset / 録音 / webhook。API は Cloudflare API (/accounts/{account_id}/realtime/kit/{app_id}/…) を Cloudflare API トークン (Bearer) で呼ぶ (旧 Dyte の Org ID / API Key + Basic 認証は使わない)。App / preset / webhook は Cloudflare ダッシュボードで設定する |
体験フロー
- メンバーがチャンネルで
/yamabikoを実行する yamabiko-slackが署名検証 → 即時エフェメラル応答 →ctx.waitUntilで RealtimeKit Meeting を作成 (record_on_start: true) し、「🎙 ハドル開催中」メッセージ (参加ボタン付き Block Kit、初回投稿のみ@here付き) をチャンネルへ投稿、チャンネル名へ開催中プレフィックス🎙-を付け (best-effort、F-09)、状態を KV へ保存する。開催中に再実行した場合は既存リンクをエフェメラルで返す (冪等)- 参加ボタン →
yamabiko-webの/room/:meetingId。未サインインなら/sign-in(Sign in with Slack / OIDC) を経て、loader が Add Participant →authTokenでブラウザの通話 UI (音声 + 画面共有) に入る - RealtimeKit webhook (参加/退出) を
yamabiko-slackが受け、Slack メッセージの参加者リストをchat.updateする - 全員退出または meeting 終了でハドル終了。メッセージを「🎙 ハドル終了 (N分)」へ更新し、チャンネル名を元へ戻して (best-effort)、Workflow
yamabiko-postcallを起動する - Workflow が録音のアップロード完了を待ち → R2 へ退避 → Gemini が録音を直接聞いて日本語のまとめ (要約 / 決定事項 / TODO) を生成 → ハドルメッセージのスレッドへ投稿する
機能一覧 (MVP)
| ID | 機能 | 実装 |
|---|---|---|
| F-01 | ハドル開始 (1 チャンネル 1 ハドル、冪等)。webhook を取りこぼして active のまま残ったハドルは、開始 10 分経過後の再実行時に RealtimeKit の active session を確認して自己修復 (終了処理してから新規作成) する | startHuddle (application/usecases/start-huddle.ts) |
| F-02 | 参加リンク投稿 / 更新 (Block Kit) | renderHuddleMessage (application/huddle-message.ts) + SlackApiNotifier |
| F-03 | 通話ルーム UI (音声 + 画面共有) | apps/yamabiko-app /room/:meetingId + RealtimeKit React UI Kit |
| F-04 | サインイン (better-auth / Sign in with Slack) | packages/auth + /sign-in /api/auth/* |
| F-05 | 参加者同期 (webhook → メッセージ更新) | handleMeetingEvent (application/usecases/handle-meeting-event.ts) |
| F-06 | 終了判定 (全員退出 or meeting.ended)。一度も参加者が居なかったハドルは録音が無いため postcall を起動しない | endHuddle (application/usecases/end-huddle.ts、冪等) |
| F-07 | 録音の回収と R2 退避 | Workflow yamabiko-postcall + R2RecordingStore |
| F-08 | AI まとめのスレッド投稿 (Gemini 音声・動画理解)。15MB 以下は inline、超は Gemini Files API (最大 2GB / 48h 自動失効 / 64MB 超はストリーミングアップロード)。動画 45 分超は低解像度 (MEDIA_RESOLUTION_LOW) で送り、コンテキスト上限の目安 (動画 170 分 / 音声 9 時間) を超える録音はスキップ | summarizePostcall + GeminiMediaSummarizer (AI Gateway 経由) |
| F-09 | ハドル開始の可視化: 開始時にチャンネル名へ 🎙- プレフィックスを付け、終了時に元へ戻す。conversations.rename はチャンネル作成者 / Workspace Admin / Channel Manager しか実行できず、bot が作成していないチャンネルでは not_authorized になりうるため best-effort (失敗してもハドルは続行し、channelRenamed: false で復元もスキップ)。装飾済みの名前には二重に付けず、復元失敗で残ったプレフィックスは次のハドル終了時に自己修復される。開催メッセージの初回投稿には @here を含める (chat.update には含めず再通知を避ける) | decorateChannelName (application/channel-name.ts) + startHuddle / endHuddle |
非スコープ (フェーズ 2 以降)
- ビデオ (カメラ映像)
- 録音ファイルの共有 UI (録音は R2 に残るが配信導線は無い)
- Web UI からのルーム作成・常設ルーム
- 社外ゲストの参加
- DM / グループ DM でのハドル
- Slack ステータス連動・通話中リアクション
データモデルとストレージ
- KV
HUDDLE_KV(両 Worker で同一 namespace を共有)huddle:channel:<channelId>— チャンネルの現在のハドル (JSON)huddle:meeting:<meetingId>— 同じ JSON の meeting 引き (webhook / ルーム UI 用)。チャンネルで新ハドルが始まっても旧 meeting キーは残り、postcall が参照できる- JSON には終了時のチャンネル名復元用に
channelName(装飾前の名前) とchannelRenamed(開始時に rename できたか) を含む (F-09) - active は TTL なし、ended は TTL 7 日で自動消滅
- R2
yamabiko-recordings—recordings/<meetingId>/<recordingId>に録音を退避 (ダウンロード URL が期限付きのため) - D1
tied-auth— better-auth の user / session / account / verification。共有 DB として複数 Worker からバインドする。drizzle 定義はpackages/d1/schema/tied-auth.ts(@tied-workspace/authが import する)、migrations はpackages/d1/migrations/tied-auth/が正本 (D1 スキーマ統一管理の規約)。適用はbunx wrangler d1 migrations apply tied-auth --remote -c packages/d1/wrangler.toml
認証基盤 (better-auth) の位置づけ
リポジトリ規約からの意図的な逸脱
本リポジトリの規約は「アクセス制御は Cloudflare Access (Zero Trust) に任せ、アプリに認証ロジックを書かない」です。やまびこの通話ルームはこの規約から意図的に逸脱し、better-auth によるアプリ層認証を採用します。理由:
- Slack のメッセージから誰でも開ける動線であり、将来のゲスト参加 (フェーズ 2) も視野に入れると、Access の組織メンバー前提モデルと相性が悪い
- better-auth + 共有 D1 をワークスペース共通の認証基盤として整備する方針が決まっており、本アプリがその最初の利用者となる
ただし認証ロジックは packages/auth のみに置き、アプリ個別には書かない (CLAUDE.md の Conventions にも例外として明記済み)。
- IdP は Sign in with Slack (OpenID Connect)。better-auth に組み込みプロバイダーが無いため
genericOAuthプラグイン + Slack の OIDC discovery (https://slack.com/.well-known/openid-configuration) で構成する - Slack OIDC の
subクレームは Slack ユーザー ID のため、account.accountIdに Slack ユーザー ID が入る。ルーム参加時はこれを RealtimeKit のcustom_participant_idに渡し、webhook の参加者と Slack ユーザーを突合する - OIDC コールバック URL:
${BETTER_AUTH_URL}/api/auth/oauth2/callback/slack - 他サービスから使う場合: 同じ D1 をバインドし
createAuthを同じ secret / 各アプリの baseURL で mount する。サブドメイン間 SSO (cookie ドメイン共有) は必要になった時点で設計する
設計の不変条件 (Invariants)
- 通話メディアは RealtimeKit に委譲する: SFU・シグナリング・帯域制御を自前実装しない。クライアントは Core SDK + UI Kit を使う
- Slack の 3 秒 ack を守る: スラッシュコマンドは署名検証 → 即時応答 →
ctx.waitUntil(suteki-bot / noisy-crow-events と同じパターン) - 公開 Worker の真正性検証: Slack 起点は Slack 署名 (HMAC-SHA256、5 分リプレイ窓)。RealtimeKit 起点の webhook は RealtimeKit の公開鍵による RSA-SHA256 署名検証 (公開鍵は
/.well-known/webhooks.jsonから取得・キャッシュ)。webhook にシークレットは存在しない - webhook 処理は冪等にする: 参加は participant id で重複排除、終了済みハドルへのイベントは無視 (postcall の二重起動を防ぐ)
- 録音要約を HTTP リクエスト内で実行しない: 録音の完成待ち + ダウンロード + AI 呼び出しは長時間処理のため、必ず Workflow
yamabiko-postcallで実行する。Workflow が失敗した場合はスレッドへ失敗の痕跡を残してから fail させる (黙って消えない) - AI 呼び出しは AI Gateway 経由 (リポジトリ規約を踏襲)。録音は書き起こしを介さず Gemini の音声・動画理解に直接渡す
- 認証ロジックは
packages/authのみに置く: 共有認証基盤の変更は利用する全サービスへの影響を確認してから行う - クリーンアーキテクチャを崩さない:
domain→application→infrastructureの依存方向を保つ。共有コアは framework 非依存 - TypeScript の相対 import に拡張子を付けない (
moduleResolution: "bundler"統一)
運用 / デプロイ
初回セットアップ
Cloudflare リソース作成
KV
yamabiko-huddle(2d5bf5b9f2244cf98d8702340afd4b70) と D1tied-auth(68870711-c65a-46a4-a82e-0737c91a565b) は作成済みで、各 wrangler.toml に id 設定済み。残りは:bashbunx wrangler r2 bucket create yamabiko-recordings bunx wrangler d1 migrations apply tied-auth --remote -c packages/d1/wrangler.tomlAI Gateway
yamabiko-ai-gatewayをダッシュボードで作成する。Authenticated Gateway を有効化し、Google API キーはゲートウェイの BYOK (stored keys) に保存する。Worker からの Gemini 認証はAI_GATEWAY_TOKENのみで行い、GOOGLE_API_KEYは Worker に持たないRealtimeKit: App は作成済み (App ID
db6fc7e4-d7c1-4037-8050-6c6c57a4a76e、両 wrangler.toml のREALTIMEKIT_APP_IDに設定済み)。残りは: RealtimeKit 権限を持つ Cloudflare API トークンを作成しREALTIMEKIT_API_TOKEN(secret) に設定。preset (既定名group_call_participant、音声 + 画面共有のみ・ビデオ無効) を作成。webhook をhttps://<yamabiko-slack のドメイン>/realtimekit/webhooksに登録する (登録は name / url / events のみでシークレットは無い。真正性は公開鍵署名で検証する)Slack App: 専用 App を新設 (suteki-bot とは分離)。スラッシュコマンド
/yamabiko→https://<yamabiko-slack のドメイン>/slack/commands。Bot scope:commandschat:writechannels:manage(公開チャンネルの rename、F-09)groups:write(プライベートチャンネルの rename)。OIDC (Sign in with Slack) の Redirect URL:https://yamabiko.tied-workspace.com/api/auth/oauth2/callback/slack(BETTER_AUTH_URLと一致させる)。使うチャンネルにはボットを/inviteするSecrets: 各 wrangler.toml 末尾のコメントに列挙したものを
wrangler secret putで設定する
デプロイ
bun run --filter @tied-workspace/yamabiko-slack deploy # Slack 受け + postcall Workflow
bun run --filter @tied-workspace/yamabiko-app deploy # ルーム UI (yamabiko-web)
# ビルド検証のみ (デプロイしない。Cloudflare クレデンシャル不要)
bun run --filter @tied-workspace/yamabiko-slack build # wrangler deploy --dry-run
bun run --filter @tied-workspace/yamabiko-app build # react-router buildCI (GitHub Actions)
.github/workflows/deploy-yamabiko.yml (noisy-crow と同型):
- PR: 関連パス (
apps/yamabiko-*/packages/yamabiko-core/packages/auth) の変更でcheckジョブが走り、typecheck + bun test + RR7 ビルドを検証する - main へ push:
productionジョブが両 Worker を自動デプロイする (CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDは既存の repo secrets) - 横断 CI (
.github/workflows/ci.yml): path フィルタ無しで全 PR に走り、bun run --filter '*' build(yamabiko-slack は dry-run) とbun run --filter '*' testを実行する
テスト / 型チェック
bun run --filter @tied-workspace/yamabiko-core test
bun run --filter @tied-workspace/yamabiko-slack test
bun run --filter @tied-workspace/yamabiko-core typecheck
bun run --filter @tied-workspace/yamabiko-app typecheck実装時に検証が必要な点 (TODO)
初回デプロイ時に実 API で確認し、確定したらコードの TODO コメントと本表を更新すること。
| 項目 | 現在の実装 (想定) | 確認方法 |
|---|---|---|
| RealtimeKit webhook の署名ヘッダー名 | RealtimeKit の公開鍵による RSA-SHA256 (base64) を複数ヘッダー候補 (rtk-signature / dyte-signature 等) から受ける (verify-realtimekit-signature.ts)。公開鍵の所在とレスポンス形は実環境で確認済み: https://api.realtime.cloudflare.com/.well-known/webhooks.json が {success, data: {publicKey: "<SPKI PEM>"}} を返す。REALTIMEKIT_WEBHOOK_PUBLIC_KEY (PEM) で上書き可 | ヘッダー名のみ実配送ログで確定して絞り込む |
| webhook イベント名 / payload 形 | meeting.participantJoined / meeting.participantLeft / meeting.ended、participant フィールドは複数候補から拾う (webhook-event.ts) | テスト配送のログ |
| recordings API のレスポンス形 | GET /accounts/{account_id}/realtime/kit/{app_id}/recordings?meeting_id= で data[].status === "UPLOADED"、audio_download_url 優先。Add Participant の token フィールド名も揺れを許容 | 実録音で確認 |
@cloudflare/realtimekit-* npm パッケージのバージョン | ^1.0.0 を仮置き | bun install で解決確認 |
| 大きい録音の Files API 経由要約 | 15MB 超は Files API へ resumable アップロード (GeminiMediaSummarizer)。要検証: AI Gateway 経由の /upload/v1beta/files パスで BYOK のキー注入が効くか、64MB 超のストリーム body アップロードが通るか | 15MB 超の実録音で確認 |
| RealtimeKit GA 後の料金 | ベータ中は無料 | GA アナウンス時に再評価 |
| チャンネル rename の権限 (F-09) | conversations.rename はチャンネル作成者 / Workspace Admin / Channel Manager のみ実行可能で、bot が作成していないチャンネルでは not_authorized を想定。失敗時は装飾なしで続行する best-effort 実装 | 実ワークスペースの対象チャンネルで bot トークンの rename が通るか確認。通らない場合の運用 (対象チャンネルを bot に作らせる / 機能を外す) を決める。絵文字入りチャンネル名 (🎙-) が API で受理されることも併せて確認 |
参考リンク
- Cloudflare Realtime 概要
- RealtimeKit ドキュメント
- RealtimeKit REST API Quickstart — Create Meeting / Add Participant / authToken
- RealtimeKit 料金 (ベータ中は無料)
- Cloudflare Realtime / RealtimeKit 発表ブログ
- 参加者トラック分離録音 (Changelog 2026-05-28)