リアルタイムイベントと通知
Chorus は、開いているすべてのブラウザを、人とエージェントが行っていることと同期させ続けます。課題が 動く、着想が担当される、提案が承認されるといった変更が起きると、その変更は Server-Sent Events(SSE)で配信され、影響を受けるページが自動的に更新されます。エージェントのデーモンも SSE を 受信します。その通知ストリームが、起動可能なエージェントが新しい作業を知る経路です。本ページは、SSE エンドポイントとそのイベントの形、デーモンの認証方法と取りこぼした作業の回収方法、そしてデーモンを使わない エージェント(とブラウザクライアント)が呼ぶ通知 REST API を説明します。
ライブ更新はどう流れるか
Section titled “ライブ更新はどう流れるか”MCP ツール、REST API、Web UI のどれによる変更でも、保存されると変更イベントとして通知されます。 SSE エンドポイントは一致するイベントを接続された各ブラウザにストリームし、ブラウザは少し待ってから 現在のページのデータを再読み込みします。
変更を保存(MCP ツール / REST API / Web UI) → 変更イベントを発行 → GET /api/events がワークスペース + プロジェクトで絞る → ブラウザの EventSource がイベントを受け取る → 500ms 後、ページがデータを再読み込みこの更新はあえて粗い粒度です。単一のイベントはページに「あなたが見ている何かが変わった」とだけ伝え、 ページはサーバーから最新のデータを取得し直します。どのイベントにも変更後のレコードそのものは 含まれません。
SSE エンドポイント
Section titled “SSE エンドポイント”二つのエンドポイントがイベントをストリームします。どちらも GET で、どちらもブラウザのセッション Cookie
または Authorization: Bearer の API キーを受け付け(エージェントがイベントを受け取る仕組みを参照)、
どちらの応答もキャッシュされることはありません。
| エンドポイント | ストリームするもの |
|---|---|
GET /api/events?projectUuid=<uuid> | エンティティの変更イベント。あなたのワークスペースに絞られ、projectUuid が与えられたときはその一つのプロジェクトに絞られる。 |
GET /api/events/notifications | 認証された呼び出し元向けの通知イベント。これによりアプリ内の通知インジケーターがライブで更新される。エージェントのデーモンは、このストリームを主要な起動チャネルとして購読する。 |
各応答は次のヘッダーと挙動で送られます。
Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive。- 接続時、ストリームはまず
: connectedのコメントを書き、そのあとdata: <json>のフレームとして イベントをストリームします。 - ハートビート:
: heartbeatのコメントを 30 秒ごとに送り、アイドル期間や中間層のタイムアウトを 越えて接続を開いたままに保ちます。 - 切断時のクリーンアップ: クライアントがリクエストを中止したとき(タブを閉じる、遷移、ネットワークの 切断)、エンドポイントはストリームを停止し、ハートビートのタイマーをクリアします。
- マルチテナンシー: 変更ストリームは、
companyUuidが呼び出し元と一致しないイベントをすべて捨てる ため、クライアントが別のワークスペースの活動を受け取ることは決してありません。
変更イベントの形
Section titled “変更イベントの形”GET /api/events 上の変更イベントは、形が固定された JSON オブジェクトです。
interface RealtimeEvent { companyUuid: string; // マルチテナント隔離 — ストリームは一致しないイベントを捨てる projectUuid: string; // 任意の projectUuid フィルターに使う entityType: "task" | "idea" | "proposal" | "document"; entityUuid: string; // 変更されたエンティティ action: "created" | "updated" | "deleted";}イベントはどのエンティティがどう変わったかを名指すだけで、そのエンティティのフィールドは含みません。 クライアントはそれを更新の合図としてのみ使います。
クライアントの挙動
Section titled “クライアントの挙動”ブラウザは /api/events?projectUuid=<uuid> に EventSource を開き、メッセージを受け取るたびに、ページの
データを更新する前に 500ms 待ちます。この待機により、一つの論理的な動作から来る一連のイベントが単一の
更新にまとまります。たとえば提案を承認すると、提案の更新に加えて、作成された課題ごとにイベントが発生します。
タブが非表示の間は接続が閉じられ、再び表示されたときに(データの更新とともに)開き直されます。
ページを離れると接続は完全に閉じられます。
エージェントがイベントを受け取る仕組み
Section titled “エージェントがイベントを受け取る仕組み”エージェントは MCP ツールや REST API を通じて操作し、その変更はすべて変更イベントを発します。そのため、
同じプロジェクトを見ているブラウザユーザーには、エージェントの作業がライブで現れます。エージェントは SSE を
受信もします。プロジェクトの変更ストリーム(GET /api/events)を読むのはブラウザだけですが、
エージェントのデーモンは通知ストリーム GET /api/events/notifications
を購読し、このストリームが主要な起動チャネルになります。
二つの認証方法
Section titled “二つの認証方法”どちらの SSE エンドポイントも、まず Bearer トークンを確認し、なければ Cookie にフォールバックします。
| クライアント | 転送方式 | 認証 |
|---|---|---|
| ブラウザ | EventSource | セッション Cookie。自動で送信される。 |
| エージェントのデーモン | fetch でストリーム応答を読む | Authorization: Bearer cho_… と Accept: text/event-stream。 |
EventSource はカスタムのリクエストヘッダーを設定できないため、ブラウザは Cookie に頼ります。この制約は
EventSource だけのものです。デーモンは fetch でストリームを読む Node プロセスなので、ほかのリクエストと
同じようにエージェントの API キーを送ります。
保留中のターンの回収
Section titled “保留中のターンの回収”GET /api/daemon/pending-turns?connectionUuid=<uuid> は、エージェントの Bearer キーで呼び出すと、その
デーモン接続に属するセッションのうち、まだ pending(未開始)のターンを返します。これは SSE の
代わりではなく、回収のための経路です。すべてのターンは作成時に保存されるため、ある起動の SSE 配信が
失われても、ターンを見つけて実行できます。
デーモンは次の三つのタイミングでこれを読みます。
- 登録時。 SSE のハンドシェイクで接続が確認されるたびに(再接続後も含む)、デーモンはすぐに保留中の ターンを走査します。
- 接続中は 30 秒ごと。 定期的な走査により、一度も切れていない接続でも、失われた配信を回収できます。
deliver_turnの通知を受けたとき。 接続の保留中ターンを読み、通知されたターンを選んで実行に回します。定期回収の時刻も来ていれば走査も行います。
登録時・定期走査で自動再実行されるのは、対応する永続化された起動コンテキストまたは操作ペイロードを持つターンだけです。どちらもない古い保留中ターンは automatic historical replay blocked と記録してスキップされ、待機や再接続では再実行されません。ライブの deliver_turn は特定の保留中ターンを指定して実行に回せます。
回収と SSE 接続は次のように連携します。
- 無通信の分断。 ストリームが 75 秒間何も受け取らない(イベントも、サーバーが 30 秒ごとに送る ハートビートもない)場合、デーモンは接続を中断して再接続します。
- 再接続中は回収を一時停止し、新しい接続が登録されるとすぐに走査から再開します。
daemon → GET /api/events/notifications (Bearer cho_…)server → connection registereddaemon → GET /api/daemon/pending-turns (sweep on registration)daemon → GET /api/daemon/pending-turns (every 30s)server → new notification / deliver_turn (primary path) … no bytes for 75s → abort, pause recovery, reconnect …daemon → GET /api/events/notifications (reconnect)server → connection registereddaemon → GET /api/daemon/pending-turns (catch-up sweep)デーモンの接続ごとに、受け付け済みのターンと通知の記録がメモリ上に一つあり、ライブのストリームと回収の
読み取りで共有されます。同じデーモンプロセスの中では、両方の経路から届いたターンも、先に見つけた経路で一度だけ
処理されます。この記録は SSE の再接続をまたいで保たれますが、デーモンの再起動はまたげません。また、
ターンの開始が拒否またはキャンセルされると記録は解放され、そのターンを再試行できます。これとは別に、
サーバーは誰も確保していない pending のターンだけを実行開始させます。いずれもエンドツーエンドの
厳密に一度(exactly-once)の保証ではありません。
デーモンを使わないエージェント
Section titled “デーモンを使わないエージェント”デーモンの下で動いていない MCP エージェントは SSE 接続を持ちません。その作業は、下記の
chorus_get_notifications MCP ツールと /api/notifications REST エンドポイントを通じて
受け取ります。
通知 REST API
Section titled “通知 REST API”通知は、ブラウザクライアントと、デーモンの下で動いていないエージェントが使う、永続的な「ポーリングして行動する」経路です。 受け手は認証コンテキストから導かれます。ユーザーとして認証されたリクエストはそのユーザーの通知を読み、 エージェントの API キーで認証されたリクエストはそのエージェントの通知を読みます。同じルートが両者を扱い、 受け手のパラメーターはありません。
| メソッドとパス | 用途 | 主なパラメーター |
|---|---|---|
GET /api/notifications | 呼び出し元(ユーザーまたはエージェント)の通知を一覧する。{ notifications, unreadCount } を返す。 | クエリ:limit(1–100、既定 50)、offset(既定 0)、unreadOnly(true で未読のみ返す)、projectUuid(一つのプロジェクトに絞り込み)。 |
GET /api/notifications/unread-count | 呼び出し元の未読数を { count } で返す。 | — |
POST /api/notifications/read-all | 呼び出し元のすべての通知を既読にする。 | 任意の JSON body { projectUuid } で一括既読の対象を一つのプロジェクトに限定。 |
PATCH /api/notifications/[uuid]/read | 一つの通知を既読にする。 | パス:通知の uuid。 |
PATCH /api/notifications/[uuid]/archive | 一つの通知をアーカイブする。 | パス:通知の uuid。 |
GET /api/notifications/preferences | 呼び出し元の通知設定を読む。 | — |
PUT /api/notifications/preferences | 呼び出し元の通知設定を更新する。 | 設定フィールドの JSON body。 |
limit はサーバー側で 1–100 の範囲に丸められ、offset は 0 未満にならないよう補正されます。呼び出し元が所有
していない通知を既読化またはアーカイブしようとすると、その通知の存在を明かさず 404 が返されます。
応答の共通形式
Section titled “応答の共通形式”すべての通知 REST ルートは、Chorus 標準の JSON 形式(エンベロープ)で応答します。成功時はペイロードを data に格納します。
{ "success": true, "data": { "notifications": [], "unreadCount": 0 } }エラー時は代わりに構造化された error オブジェクトを返します。
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Notification not found" } }よくある失敗のエラーコードは NOT_FOUND、BAD_REQUEST、UNAUTHORIZED、FORBIDDEN で、それぞれ
対応する HTTP ステータス(404、400、401、403)を返します。有効なセッションもエージェントの API キーも
ないリクエストは、次のエラーで拒否されます。
{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } }HTTP は 401 です。(SSE エンドポイントはこの形式を使いません。未認証のリクエストにはプレーンテキストの
Unauthorized とステータス 401 で応えます。)
単一インスタンスと複数インスタンスの配信
Section titled “単一インスタンスと複数インスタンスの配信”既定では、変更イベントはメモリ内で受け渡されます。単一インスタンスのデプロイにはこれで十分です。変更を 処理するプロセスが、すべての SSE 接続を保持するのと同じプロセスだからです。ローカルでイベントを発行すれば、 接続された各ブラウザに届きます。
複数のインスタンスを動かすとこの前提が崩れます。インスタンス A 上の変更も、インスタンス B に接続された ブラウザへ届ける必要があるからです。複数インスタンスのデプロイでは、イベントを Redis pub/sub で配信します。各インスタンスは共有チャンネルを購読し、変更はローカルだけでなく Redis へ発行され、 各インスタンスの SSE エンドポイントが受け取った内容を自分の接続済みブラウザへ転送します。SSE エンドポイントとブラウザ側は変わりません。違うのはインスタンス間でイベントを運ぶ方法だけです。
本ページは配信のしくみだけを扱います。何が通知を生成するか(カテゴリー、@メンション、活動の種類ごとに 誰が通知されるか)や、各受け手が受け取るかどうかを選べる設定については、コラボレーションリファレンスを ご覧ください。