grokbot-bridge
by kitepon
README.md
# grokbot-bridge
Standalone Docker image candidates retain their own archive, config and OCI
manifest identities. See [Identifying downloaded image candidates](docs/image-identities.md)
when comparing a loaded image across Docker Engine image stores.
## BellTeamとの直通
電話帳はGrokBotのプロフィールとBellTeamのBot一覧を要求ごとに合わせて返す。各項目の`system`と`id`で宛先を区別する。BellTeam側のUNIXソケットを`CALL_BRIDGE_BELLTEAM_UNIX`に設定し、その親ディレクトリをコンテナの`/run/bellteam`へマウントする。BellTeam側には`BELLTEAM_CALL_BRIDGE_SOCKET`を設定する。BellTeam宛ての本文とBellTeam発信者宛ての返信はこのソケットを通り、マリアンは通らない。
BellTeam宛ての配送受付は最大30秒待つ。UNIXソケットへの接続前の失敗は`delivery.status=error`で本文を保存しない。接続後のタイムアウトやHTTP 5xxは、BellTeam側へ届いた可能性があるため`delivery.status=unknown`として本文を通話履歴へ保存する。`unknown`を見て同じ本文を自動再送しない。BellTeamの通話記録と配送状態を`session_id`で確認してから対応を決める。`delivered`はBellTeam受付を示し、相手Botの読了を示さない。
BellTeam宛ての開始例。`member_name`は名前またはBot IDを指定できる。同名のBotがいる場合はIDを指定する。BellTeamのBotが発信する場合は`local_system="bellteam"`と自身のBot IDを`local_id`に指定する。
```json
{"local_id":"caller-id","local_label":"呼び出し元","member_name":"bot-xxxxxxxx","member_system":"bellteam","local_system":"local"}
```
`call_open`のあと`call_send(session_id, from_party="local", message="...")`で着信させる。BellTeam宛ての`call_open`だけでは相手Botを起こさない。返信は相手Botが同じ`session_id`へ`from_party="member"`で送る。BellTeam発信の通話ではブリッジが返信を発信Botへ渡す。GrokBot発信者と一般のローカルAIは`call_poll(party="local")`で返信を読む。ローカルCodexの既存の自動配送はそのまま使える。
GrokBot宛ても BellTeam 宛てと同じく、`call_directory` で相手を選び、`call_open` で開き、`call_send` で直接届く。GrokBot の本文は橋に保存され、箱の中の配達役が相手の席へ `sendPrompt` で届ける。届いた文はオーナー名義に見えるので、本文の冒頭に「これはオーナー本人ではなく、〇〇チームの〇〇からのメッセージです。」が付く。`call_send` では `sender_name` を指定し、所属は接続のトークンに結び付いていればそのチームになる。GrokBot の AI がレート制限中でも、電話帳、BellTeam 宛ての直通、保存済み返信の取得は独立して動く。配達役の `submitted` と BellTeam の受付は、相手 Bot の読了を示さない。
予備として、橋の `CALL_BRIDGE_GROKBOT_DELIVERY=marian` のときだけ、マリアンが本文なしの `session.opened` で相手を起こし、相手は `call_poll` で本文を読む。通常の配達と同時には使わない。
## 端末の会話が止まっている時
端末(local)が開いた通話へ member が返した時、結び付いた会話が返信を受け取れない状態なら、端末の側が担当フォルダの席へ渡す。通話の `session_id` は変わらないので、member は今までどおり同じ通話へ返せばよい。
- 席は Aiterm の session で、端末・ハーネス・フォルダごとに1つ(名前は `cb-<ハーネス>-<フォルダ名>-<8桁>`)。動いていればそこへ送り、無ければ立てる。止まった会話に付いていた通話は、その席へ付け替える。
- Codex の会話は、キューへ入れる前に状態を読む。会話が無い・途中で止められている時は、Throughline の続きの会話(`throughline auto-handoff status --json`)があればそこへ付け替え、無ければ席へ渡す。中断から120秒以内は待つ。
- キューへ入れた返信は、50秒後にキューから出たかを確かめる。番が走っていれば待つ。寝たまま・止められたままなら、キューから取り消したのを確かめてから席へ渡す。確かめられない時は `unknown` で止め、渡さない。
- Claude Code の会話は、hook が入っていれば、返信を会話へ自動で渡す(`call_open` の結果の `parent_delivery.state` が `watching`)。番を終えて止まっている会話は、返信で起きる。作業中の会話には、その番へ入る(同じ番の2通目からは、番の終わりに入る)。
- Claude Code の会話へ入れた返信は、10秒後に会話へ出たかを確かめる。誰も取り出していなくて、会話も生きていない時は、取り下げてから席へ渡す。取り下げた返信が何通かあれば、まとめて1回で渡す。生きている会話(channel を開いた process が居るか、会話の記録がこの2分のうちに書かれている)は、30分まで待つ。会話が終わった時は hook が受け口を閉じるので、次の返信はすぐ席へ渡る。
- 会話は、起こし直しや引き継ぎで替わる。Codex か、hook の入った Claude Code の会話が、local として `call_send` か `call_poll` を呼ぶと、以後の返信はその会話へ届く(`call_adopt` を呼ばなくてよい)。前の会話が開いた通話も、席が受け取った通話も、使った会話へ付いて行く。`codex exec` の親へは付け替えない。
- 届け先が別の会話へ移る時、前の会話の受け口に並んだまま読まれていない返信は、取り下げて新しい会話へ渡し直す。前の会話がもう取り出した返信は、重ねて渡さない。前の会話が止まっている・Throughline に引き継がれた時に、並んだ返信がだれにも届かずに「届いた」扱いになる事があった(2026-10-11、fox)。
- Claude Code の会話が Throughline に引き継がれて止まった時(`throughline auto-handoff status --host claude --operation <会話> --json` の state が stopped・launching・launched・sent・unknown)は、その会話の受け口へ返信を入れない。引き継がれた会話は道具を使えず、起こしても文で答えるだけになり、送った側には「届いた」と見えたまま何も進まない(2026-10-11、fox)。返信は持っておき、後継の会話が通話を使った時にそこへ渡す。30分たっても後継が使わなければ、担当フォルダの席へ渡す。state が requested(予約だけで、会話はまだ動く)と failed、記録なし、Throughline が無い時は、今までどおり渡す。後継の受け口を外から作る事はしない(受け口は、会話が自分で通話の道具を呼んだ時に開く)。
- Claude Code に止められた裏の会話(`claude --bg`。Throughline が立てる後継はこの形)は、席へ渡さずに、同じ会話のまま起こす。Claude Code は、裏の会話が番を終えて入力を待ったまま約1時間たつと、その process を止める(会話の記録は残る。Claude Code の log に、会話が「済んだ」と申告していれば `bg retire <会話>: settled, idle 61m`、利用者の答えや確認を待つと申告していれば `bg retire <会話>: idle-prompt, idle 61m`)。相手の返信を待っている役の会話が、これで止まる。席は別の会話なので、渡すと通話の履歴から役を読み直す事になり、同じフォルダの別の役と同じ席に重なる(2026-10-11、fox)。受け口へ入れられず、`claude agents --json --all` の一覧でその会話が裏の会話(kind=background)で、state が done(済んだ)か blocked(利用者の答えや確認を待っている)で(Claude Code が自分で止めた会話は、最後に申告した state のまま残る)、どこでも動いていない(pid の付いた行が無い)時に、`claude --bg --resume <会話> <知らせ>` で起こす。同じ番号・同じ記憶・保存された起動の設定のまま起きる。blocked は 0.3.9 から起こす(それまでは席へ渡していた。2026-10-12 に fox で、確認待ちで止まった役の会話あての返信が席へ渡り、席の中身は Throughline に引き継がれた後の会話で、何も出来なかった)。`--model` や `--effort` を足して呼ぶと、Claude Code は同じ会話を起こさずに写し(`--fork-session`)を立て、保存された設定(承認の形・名前)を落とす(2026-10-12 に fox で確かめた)ので、知らせのほかに何も足さない。起きた会話のモデルと思考の強さは、その会話が立った時の物のままになる。知らせは、決まった文と通話の番号だけの1行で、「作業の前に `call_adopt` で引き取る」と書いてある。返信の本文と相手の名前は引数に入れない(引数は、同じ端末のほかの process から見える)。返信は、起きた会話が引き取った後に、いつもの受け口から渡る。起こすのは、受け口ごとに1回。命令が失敗で終わった時だけ「起こせなかった」と決める。時間切れや、起きた印(`backgrounded · <頭8字>`)を出力から読めない時は決めずに、次に一覧を読んで、動いていれば引き取りを待ち、動いていなければ席へ渡す(起きた会話と席の両方へ渡さない為)。次の時は、今までどおり担当フォルダの席へ渡す:起きた会話が30分たっても引き取らない、起こせなかった、裏の会話でない(人が閉じた会話など)、仕事の途中で人が止めた(state が stopped。止めた仕事を、返信が来たからといって動かし直さない)、アプリなど別の所で動いている(そこへ起こす命令を出すと、Claude Code が写しを立てる)。Throughline に引き継がれて止まった会話は起こさない(上の扱い)。
- 後継の会話には、前の会話が持っていた通話を知らせる。Claude Code の UserPromptSubmit の hook(`steer/call-bridge-claude-prompt.mjs`、`call-bridge-setup harness claude-code enable` が登録する)が、後継の最初の文(「Throughline自動継続 <引き継ぎID>」)を見た時だけ動き、`throughline auto-handoff status --host claude --operation <引き継ぎID> --json` で前の会話を引いて、その会話の受け口へ届けている通話があれば、「最初に `call_adopt` で引き取る」を追加の文脈として渡す。引き取る道具は、後継の会話が自分で呼ぶ。ほかの文では、何も出さずに終わる。失敗しても文は止めない(必ず 0 で終わる)。
- Cursor と Grok の会話は、待ち受けの命令で返信を受け取る(`parent_delivery.state` が `wait`)。`call_open`・`call_send`・`call_poll` の返りの `parent_delivery.wait_command` を、会話が背景の process として動かす。返信が届くと、その命令が1行の JSON(`deliveries` に本文、`next_wait_process` に張り直しの命令)を出して終わる。Cursor は、作業中なら hook が次の道具の返りへ返信を差し込む(道具の返りの `steer_channel` が、受け口を会話へ結ぶ印)。この2つには、番を終えて何も張っていない会話を外から起こす口が無い。待ち受けが張られないまま30分たった返信は、取り下げて席へ渡す。
- 配送のパッケージや hook の無い端末の会話は、返信を自分で `call_poll` する(`parent_delivery.state` が `manual`。`reason` に理由が出る)。会話が生きている間は、その会話の MCP が通話の鍵を持つ。会話が終わった後に届いた返信は、常駐の受け取り係が席へ渡す。
- 確かに届けていない失敗(席が起動の画面で止まった、フォルダが分からない等)では見張りを止めず、同じ通話に新しい返信が来た時か、失敗から30分たった時に、その返信からやり直す。受け取り係が起き直した時も、すぐ1回やり直す。新しい返信だけを出口にすると、相手が送り直さない返信は届かないまま残る(2026-11-10、Mac で、席の起動が `unrecognized_screen` で止まった seq55 が、そのまま保留された)。図は [docs/architecture/held-reply.md](docs/architecture/held-reply.md)。
- 立てた結果・送った結果が分からない時は、立て直さない・送り直さない。本文は通話に残る。
channel・手動受信の会話が通常の `call_poll(party="local")` で同じ返信を取得すると、
予約済みで止まっていた返信も `fetched` として進める。取得を終えてから見張りを再開し、
その返信は再送しない。元の `unknown` の原因はローカル配送行の `error` に残す。
履歴の参照や見張りの peek は、この取得印を付けない。`fetched` は Bridge からの取得記録で、
Steer hook の出力・本人の実行や読了の確認ではない。別に残る Steer の未出力分も取り下げない。
Claude Code の自動受信が `unknown` で止まった場合は、更新した常駐の受け取り係も取得印を読む。
未確定予約の全件が通常取得済みで、通話・宛先・予約が観測中に変わっていなければ、
元の原因を残して見張りを再開する。既存MCPや会話の再起動は要らない。
一部未取得・不正応答・宛先変更・停止との競合では再開せず、元本文も再送しない。
member から見える物:
- `call_send(from_party="member")` の結果。相手が BellTeam 以外なら `delivery.status=stored` と、相手が最後に取りに来た時刻(`local_seen_at`)。`stored` は受領を表さない。
- `call_info` の `local_delivery`。seq ごとに `submitted`(会話のキューか席へ入れた)、`started`(会話が番を始めた)、`fetched`(会話が自分で取りに来た)、`relaunched`(席へ渡した。`detail` に理由)、`failed`、`unknown`。`conversation` は `codex:<会話>`、`claude:<受け口>`、`cursor:<受け口>`、`grok:<受け口>`、`hosted:<席>` のどれか。
- `call_history`。両方の発言を seq の順に読む(既読にしない)。席は、これで前のやりとりを読む。
端末の側の道具:`call_adopt(session_id)` は、新しい会話(Codex か、hook の入った Claude Code)が通話を自分へ付け替える。席が受け取った通話を、人が見ている会話へ戻す時にも使う。
### 常駐の受け取り係
Codex が1つも動いていない時と、Claude Code・Cursor・Grok の会話が終わった後に、返信を受け取る。ログイン中だけ動き、端末に1つ。`call-bridge-setup enable` が登録して起こし、`disable` が外す。
受け取り係が居る端末では、通話の見張りは受け取り係が握る(0.3.11 から)。見張りは、通話ごとの鍵(`<通話>.lock`)を先に取った process が続ける。会話の中の通話MCP(`call_bridge.local`)は、起きた時の版のまま何日も動き、ほかの通話の鍵も待っているので、受け取り係が起き直すたびに古い版の process が鍵を取り、その通話だけ古い作りで扱われていた(2026-10-12 に Mac で、10/6〜10/11 に起きた process が 50 本を握っていた。その版は、控えた Node の場所が無くなると渡せず、席を立てる時にモデルも渡さない)。受け取り係は、20秒待っても握れない通話について、握っている見張りに手放すよう頼む:控えの state を `yielding` にすると、握っている見張りも、鍵を待っている見張りも、次の見回りで降りる(0.2.0 からの作り)。鍵が空いたら握り、state を `active` に戻す。返信を渡している最中の見張りは、渡し終えてから降りる。30秒待っても空かなければ頼みを下ろし、3回までやり直す。頼んだまま受け取り係が落ちた分は、次の受け取り係が起きた時に戻す。`yielding` の間も、通話の持ち主(`call_info`)と、届け先の付け替え・引き継ぎからは、生きている通話として扱う。会話が自分で取りに来る通話(manual)は、その会話の通話MCPが鍵を持っている事が「会話が生きている」印なので、頼まない。0.2.0 より前の process は頼みを読まないので、握ったまま残る(受け取り係の記録に `stays with a process that does not let go of it`)。その時は、その process を止める。受け取り係はパッケージを入れ直すたびに起き直すので、直しは、前から動いている会話の通話にも届く。
`call-bridge-setup enable` は、Codex・Node・Aiterm の場所を `~/.grokbot-bridge/config.json` に控える。控えるのは PATH にある入口で、版つきの実体ではない(Node は 0.3.0 から、Codex は 0.3.11 から。npm の Codex だけは、Node で直接起こすので `codex.js` の実体を控える)。版つきの実体を控えると、道具を上げた後も古い版を使い続け、古い版が片付けられた時に止まる(2026-10-12 に Mac で、Homebrew が Node を上げて控えた場所が無くなり、古い版の process が席を立てられなくなった。Codex は7つ前の版の場所が控えられていた)。控えた場所に物が無い時は、今の PATH から探す。
| OS | 登録先 | 動く場所 |
|---|---|---|
| macOS | `~/Library/LaunchAgents/dev.kitepon.call-bridge.receiver.plist` | 画面のある session(Aqua) |
| Linux | `~/.config/systemd/user/call-bridge-receiver.service` | 利用者の session(linger は設定しない) |
| Windows | タスク スケジューラの `call-bridge-receiver`(ログオン時) | 利用者の画面のある session |
```bash
call-bridge-setup receiver status # {"receiver": "running" | "registered" | "not_registered" | "failed: …"}
call-bridge-setup receiver install
call-bridge-setup receiver uninstall
```
Windows の常駐は console を持たない(`pythonw`)。動いている間に起こす子の process(Codex の App Server、aiterm-steer-delivery、Throughline、Aiterm、claude)は、端末の窓を出さない指定で起こす。指定が無いと、起こすたびに端末の窓が 0.2〜0.4 秒出る(2026-10-10 に fox で測った)。
席を立てるには、その端末に `aiterm-mcp`(0.57.0 以上)、tmux(Windows は psmux)、使うハーネスの CLI が要る。
Claude Code の席へ返信を入れる前に、席の中身の会話が Throughline に引き継がれた後でないかを確かめる(0.3.10 から)。引き継がれた後の会話は、Throughline が道具を全部断る。席は一覧に居て、文も入るが、読む事も返す事も出来ず、送った側には `submitted hosted:<席>` と見えたまま何も進まない(2026-10-12 に fox で、3通がそうなった。後継は裏の会話で、席の端末には居ない)。席の会話の番号は Aiterm の `pty_list` の `vendor_session_id`(`aiterm-mcp` 0.58.3 以上。Claude Code では、起動の時に `--session-id` で渡した番号)から読み、`throughline auto-handoff status --host claude --operation <番号> --json` の state が stopped・launching・launched・sent・unknown なら、席を閉じて、同じ名前で新しい席を立てて渡す。receipt は `relaunched`、理由は「席の会話は Throughline に引き継がれていました(後継 <頭8字>)」。番号が分からない時(前の版の Aiterm、ほかのハーネスの席)と、Throughline が無い・読めない時は、今までどおり席へ入れる。
Claude Code の席は、モデルと思考の強さを渡して立てる(Aiterm の `model`・`reasoning_effort`。Claude Code では `--model`・`--effort` になる)。既定は `claude-opus-5-5`・`high`。渡さないと、その端末の `~/.claude/settings.json` の既定で立つ(2026-10-11 に Mac で、止まった会話の役を引き継ぐ席が sonnet・low で立ち、そのまま仕事を任されていた)。端末ごとに変える時は、`~/.grokbot-bridge/config.json` の `claude_seat_model`・`claude_seat_effort` に書く。Codex・Cursor・Grok の席は、今までどおりその端末の設定で立つ。席で動く Claude Code の `/model`・`/effort` は使わない(選んだ値が利用者の `~/.claude/settings.json` へ「新しい会話の既定」として書かれる)。
Claude Code の席へ返信を渡す時は、本文の前に1行の前置きを付ける(Aiterm の `preface`。貼り付けの外へ出る)。Claude Code は、貼り付けられた文の中の指示を「利用者が書いた物ではないかもしれない」と扱うので、前置きが無いと、席が返信のとおりに進めず、利用者へ伺いを立てて止まる事がある(2026-10-11 に fox で、立てた席が Approval Box へ申請した)。Claude Code の席は、文を渡さずに立ててから、前置きを付けて最初の文を送る。Codex・Cursor・Grok の席は、今までどおり起動の時に文を渡す。
寝ている Codex アプリの会話を起こすのは aiterm-steer-delivery 0.4.1 以上(macOS・Windows)。
### Claude Code・Cursor・Grok への登録
```bash
call-bridge-setup harness claude-code enable # status/disable も同じ形
call-bridge-setup harness cursor enable
call-bridge-setup harness grok enable
```
各 CLI の利用者の設定へ、ローカル MCP(`python -m call_bridge.local`)を `call-bridge` の1項目だけ書く。合言葉は、この端末の `auth.json`(Codex で `enable` 済み)か、環境変数から受け取って `auth.json` に置く。席から通話へ返すにも、この登録が要る。
`status` の `delivery` が、返信の渡し方。`automatic`=返信で会話が起きる(Claude Code)。`wait`=会話が待ち受けの命令を動かしている間に届く(Cursor・Grok)。`manual`=会話が自分で取りに来る。
Cursor には、作業中の会話へ返信を差し込む hook を登録する(`~/.cursor/hooks.json`。`CURSOR_HOME` があればその中。ほかの hook は変えない)。Grok は hook を使わず、待ち受けの命令の入口だけを置く。
Claude Code には、返信を会話へ自動で渡すための hook を登録する。
- 使うのは共通パッケージ aiterm-steer-delivery(0.4.2 以降)の channel。Claude Code の公式の hook(`asyncRewake`)が、止まっている会話を起こして本文を渡す。パッケージの CLI は Codex だけなので、call-bridge の Node の入口(`src/call_bridge/steer/`)から呼ぶ。入口は置き場の `steer/` へ写し、パッケージの場所を隣の `steer.json` に書く。入れ直した後は、もう一度 `enable` を流す。
- 登録先は Claude Code の利用者の設定(`~/.claude/settings.json`。`CLAUDE_CONFIG_DIR` があればその中)。`PreToolUse`・`PostToolUse`(`call_open`・`call_adopt`・`call_send`・`call_poll` だけ)、`Stop`、`SessionStart`、`SessionEnd` に1つずつ足す。ほかの hook は変えない。書き換える前の控えが `settings.json.call-bridge-backup` に残る。
- 入れ直す前から動いている会話は、その会話の通話MCP(`call_bridge.local` の process)が古いままなので、今までどおり自分で取りに来る。Claude Code を起動し直すか、`/mcp` で call-bridge をつなぎ直した後に、その会話が通話を使った時(`call_send`・`call_poll`)から効く。hook そのものは、動いている Claude Code にも設定の書き換えだけで効く(Claude Code 2.1.296 の Linux で確かめた。hook を入れる前から動いていた会話が、後から入れた hook で受け取れた)。
- パッケージが無い・古い端末でも、ローカル MCP の登録は済ませる。その時は `warning` に理由が出て、`delivery` は `manual`。
- 待ち受けは24時間で切れる。丸1日だれも話しかけていない会話は起こさず、返信は30分後に席へ渡る。
### Windows で入れ直す時
`uv tool install --force` は、動いている process が掴んでいるフォルダを消せずに途中で止まり、入っていた版が壊れた状態で残る。先に名前を変えてから入れる。
```powershell
schtasks /End /TN call-bridge-receiver
Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*call_bridge.receiver*" } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
Rename-Item "$env:APPDATA\uv\tools\grokbot-bridge" "grokbot-bridge.old-$(Get-Date -Format yyyyMMdd-HHmmss)"
uv tool install git+https://github.com/kitepon/grokbot-bridge.git
$env:CALL_BRIDGE_TOKEN = (Get-Content "$HOME\.grokbot-bridge\auth.json" | ConvertFrom-Json).token
call-bridge-setup enable
```
古いフォルダは、掴んでいる process(Codex が起こしたローカル MCP)が終わってから消す。`enable` は、aiterm-steer-delivery と node が PATH に見える所で流す。`enable` が配送の hook を登録し直した時は `restart_required` になり、Codex を起こし直すまで、動いている Codex の会話は新しい通話を開けない。
### 続きの会話(Throughline)
Throughline 0.16.15 以降では `throughline auto-handoff successor --thread <id> --json` で続きを引く(件数の上限が無く、消された続きは数えない)。続きを作っている途中(`pending.in_flight`)は待つ。引き継ぎが途中で止まっている時は、引き継ぎ ID と理由を付けて `failed` にし、席は立てない(`throughline auto-handoff resume` が正しい入口)。それより古い版では、`auto-handoff status --json` の一覧(20件まで)からたどる。
### 確かめた範囲(2026-10-10、本番の通話)
席が立つ → 通話を付け替える → member が `call_info` で結果を読める → 席が `call_send` で同じ通話へ返す、を実物で通した。
| | Linux | macOS | Windows |
|---|---|---|---|
| Codex | 通った | 通った | 通った |
| Claude Code | 通った | 通った | 通った |
| Cursor | 通った | 通った | 通った |
| Grok | 通った | 通った | 通っていない(席は立ち、文は入った。Grok の `user_prompt_submit` の hook が時間切れになり、その後 Grok が答えなかった) |
常駐の受け取り係は、3つの OS とも登録して動かした(systemd のユーザー単位、LaunchAgent、ログオン時のタスク)。
確かめていない事:
- 各ハーネスの会話の中から `call_open` する所(親の見分け、フォルダの読み取り)。試験では、端末で通話を開いて結び付けを直接作った。
- Codex で、止められた会話あての返信が続きの会話へ届く所(続きを引く所だけ、本物の記録で見た)。キューへ入れた後の確かめと取り消し。
- 常駐が、ログアウト・ログイン・再起動の後に戻る所。
席は、承認を聞かない形で立つ(Claude Code は bypass permissions、Cursor は Run Everything、Grok は always-approve)。立った席は、その端末でほぼ何でも実行できる。
Codex のアプリの会話として新しく立てる道は、入っていない(立つのは端末の中の席)。`codex exec` の親は今までどおりで、次のプロンプトまで返信を待つ。
Shared **phone-call bridge** MCP for **[Grok Bot](https://grok.x.ai/)** agent meshes (streamable HTTP).
A local coding agent (Claude Code, Codex, Cursor, …) calls either system through this MCP. Pick the person with `call_directory`, open with `call_open`, and deliver with `call_send`. GrokBot members are woken by a courier on the Grok Bot box (`sendPrompt`). BellTeam members are reached through BellTeam's UNIX socket. Member replies remain in the call history; replies to a BellTeam caller are also delivered to that Bot.
## Why
- `deliverAgentMessage` on the host gateway lands in a box-local New Agent conversation, so it is not used. The box courier calls `POST /api/sendPrompt` on `127.0.0.1` instead. That prompt shows up under the owner's name, so every local message starts with a fixed line naming the real sender and team.
- The bridge runs outside the box and cannot reach `127.0.0.1` there. The courier connects outward, claims each message once, and reports the outcome with `call_receipt`. An uncertain outcome is not sent again.
- `CALL_BRIDGE_GROKBOT_DELIVERY=marian` keeps the previous ring: the text stays in the bridge, Marian posts `session.opened` with no body, and the member reads it with `call_poll`. Leave that off while the courier is running.
- For a BellTeam target, local `call_send` uses BellTeam's UNIX socket. A reply to a BellTeam caller also uses that socket.
- Both routes use the same MCP endpoint and `session_id`.
## Flow
### GrokBot target
1. **Local** opens a session (`call_open` or `POST /v0/sessions`) and gets a `session_id`. Nothing is posted yet; `wake.status` is `skipped`.
2. **Local** `call_send` accepts `sender_name`. When the bearer token is bound to a system, `sender_team` is that system and a different team is rejected. When the token also names a caller id, GrokBot and BellTeam take the display name from the phone book. A local token takes the name registered on that token, because local ids are not in the phone book. A different name is rejected. The stored text starts with 「これはオーナー本人ではなく、〇〇チームの〇〇からのメッセージです。」. In the default `courier` mode the bridge saves the text and returns `delivery.status=queued`. The box courier claims it, POSTs `sendPrompt`, and reports `submitted`, `failed`, or `unknown` through `call_receipt`. `unknown` is not sent again. `CALL_BRIDGE_GROKBOT_DELIVERY=marian` instead rings with `session.opened` (no message body) when the member has nothing unread; a failed ring does not store the text.
3. **GrokBot member** receives the prompt directly in courier mode. `call_poll(party="member")` still returns the stored copy, including the reply hint when `reply_required=true`. The member's `call_send(from_party="member")` stores the reply. If the caller is a BellTeam Bot, the bridge also delivers that reply through BellTeam's UNIX socket.
4. Either side (or ops) calls `call_hangup`.
### BellTeam target
1. **Local** `call_open(member_system="bellteam")` creates the session without waking a Bot.
2. **Local** `call_send` sends the body to BellTeam's UNIX socket. On confirmed acceptance, the bridge stores it and returns `delivery.status=delivered`. If the connection fails before the request, it returns `error` without storing. If the request may have been accepted but its result is unavailable, it stores the message with `delivery.status=unknown`; do not resend blindly.
3. The BellTeam member's `call_send(from_party="member")` stores the reply. If `local_system="bellteam"`, the bridge also sends that reply through BellTeam's UNIX socket to the calling Bot; that delivery can likewise be `delivered`, `error`, or `unknown`. Other callers retrieve the stored reply with `call_poll`, or receive it through the Codex local MCP when configured.
4. Either side (or ops) calls `call_hangup`.
When a real MCP or REST request finds the directory unix socket unreachable (`unix socket not found`, `timed out`, or `request failed` — the socket never produced an HTTP response), the server POSTs the same switchboard webhook once:
```json
{"event":"bridge.link_down","link":"directory","detail":"unix socket not found"}
```
`link` is `directory`. At most one of these wakes is sent per 60 seconds, in-process, with no background poll. `call_directory` uses its fallback and adds a note that the GrokBot box link is down, unless the box courier has pushed a fresh book. A GrokBot-targeted `call_send` that cannot resolve the member returns that note without storing. In `marian` mode, if fallback profiles provide an ID, it still rings with `session.opened` after the `bridge.link_down` wake. A failed ring does not send `bridge.link_down`. BellTeam delivery uses its separate UNIX socket. The bridge does not call the gateway; only the courier on the box does, and it reads `gateway.json` there.
`call_send(from_party="local")`は宛先の所属で配送先を選ぶ。送り手の名前を指定し、所属は接続のトークンに結び付いていればそのチームを使う。本文の冒頭に、オーナー本人ではなくそのチームのその人からだという一文が付く。GrokBot宛ては本文を橋に保存し、箱の配達役が `sendPrompt` で直接届ける(`delivery.status=queued`。結果は `call_receipt`)。`CALL_BRIDGE_GROKBOT_DELIVERY=marian` のときだけ、相手に未読がなければマリアンの Webhook へ本文なしの `session.opened` を送って起こす(HTTP 2xx で受付確認後に保存する)。BellTeam宛ては BellTeam の UNIX ソケットへ直接送る。受付結果が不明なら `delivery.status=unknown` で保存し、二重配送を避けるため自動再送しない。保存する本文には同じ `session_id` での返信案内を付けるが、BellTeam へ渡す本文には返信案内を付けない(送り手の一文はどちらにも付く)。`from_party="member"` の返信は保存し、BellTeam 発信者宛てだけは同じ UNIX ソケットへも届ける。返信不要の通知には `reply_required=false` を指定する。MCP と REST は同じ動作を使う。返信依頼は相手への指示であり、返答を保証しない。
`call_send` の通常送信の引数例:
```json
{"session_id":"...","from_party":"local","message":"状況を教えてください","sender_name":"Cursor","sender_team":"ローカル"}
```
旧クライアントが `sender_name` を省略した場合、所属に結び付いた接続だけを補完する。localはトークンの登録名(例:`local-macbook`)、GrokBot/BellTeamはその通話の `local_id` の電話帳名を使う。通話の自由入力 `local_label` は本人名の根拠にしない。明示した空欄、名前を解決できない接続、所属のない匿名接続は拒否する。memberの返信は従来どおり名前を要しない。利用中の旧MCPを強制再起動する必要はない。
トークンが所属に結び付いている接続では `sender_team` を省ける。省いた値は、その所属(GrokBot / BellTeam / ローカル)になる。別のチームを渡すと拒否する。
返信不要の通知の引数例:
```json
{"session_id":"...","from_party":"local","message":"共有のみです","reply_required":false,"sender_name":"Cursor","sender_team":"ローカル"}
```
REST の `POST /v0/sessions/{session_id}/messages` でも同じ項目を渡せる。
### 配達役の途絶通知(任意)
`CALL_BRIDGE_COURIER_WAKE_WEBHOOK_URL` と `CALL_BRIDGE_COURIER_WAKE_WEBHOOK_KEY` の両方を、1Passwordなどから実行時の環境へ渡すと有効になる。キーは `Bearer ` を付けない値で渡す。未設定、または `CALL_BRIDGE_GROKBOT_DELIVERY=marian` の時は通知しない。
courier専用トークンによる `inflight` / `claim` / `directory` の正常な問い合わせが5分途絶え、GrokBot宛ての配達待ちの手紙(`queued`)がある時に、10秒間隔の監視が一度だけPOSTする。途絶だけでは通知しない。途絶中に手紙が届けば次の確認で通知し、5分になる前に届いた手紙が残っている場合も通知する。終了した通話や、配達役が既に取得した手紙・配達済み・失敗・結果不明の手紙では通知しない。ヘッダーは `Authorization: Bearer <キー>`、本文は `{"event":"courier_silent","last_seen":"<UTC ISO時刻>"}`。有効化後まだ問い合わせが無い場合は5分の猶予を置き、`last_seen` は `null`。他メンバーの通信や失敗応答は生存判定に使わない。
最終問い合わせと通知済み状態は既存SQLiteに保存し、橋が再起動しても同じ途絶について通知を重ねない。送信の失敗・結果不明でも、正常問い合わせが戻るまでは再送しない。マリアン方式への切替は行わない。橋自身や送信経路が停止中の場合は通知を送れない。通知済みを記録した直後、POSTの前に橋が停止した場合も再送しない。
### Retrying a failed GrokBot call_open
When the directory UNIX link is down and the fallback cannot resolve a nonempty
target, `call_open` (also `POST /v0/sessions`) returns the existing `error` and
`detail` plus `code="directory_link_down"`, `retryable=true`,
`retry_after_seconds=30`, and `session_created=false`. The failure creates no
session, stores no message, and sends no recipient ring; the body-free operator
notification above may still occur. The directory also retains
`directory_unix_error` when using a fallback.
A caller receiving all these retry fields may retry **only call_open** after the
delay, with a finite attempt or time limit. The hint means the primary directory
was unreachable; it does not promise that the target exists or that recovery will
succeed. A healthy directory with no matching target, an empty name, an ambiguous
name, or a matched entry without an ID does not receive the hint. Older servers
do not provide it; do not infer retry permission by parsing `detail` or `note`.
This contract does not apply to transport failures with no confirmed response,
to `call_send`, or to `delivery.status=unknown`.
### Codex 親への返信自動配送
Codex から通話する端末では、ローカル MCP を登録すると `call_open` が親タスクを識別する。
ローカル MCP でも `call_open(member_system="bellteam")` でBellTeam宛てを選べる。
`member_system`の既定は`grokbot`で、遠隔MCPへ渡す。`local_system`は指定した時だけ渡し、省くと遠隔MCPが接続のトークンの所属を使う(共通トークンでは`local`)。
ローカル MCP が通話の返信を裏で取得し、Codex 親へ一通ずつ渡す。
親AI自身が `call_poll` を繰り返す必要はない。継続型の親では共通パッケージ
[aiterm-steer-delivery](https://github.com/kitepon/aiterm-steer-delivery)(Aitermと同じ配送)がCodexの公式キューへ一度だけ入れる。
進行中ならパッケージの `PostToolUse`/`Stop` hook が返信を同じターンへ差し込み、ターン終了後ならキューが次のターンとして届ける。
hook が起動する Codex は、公式 Desktop 同梱の CLI(macOS/Windows/Linux)を先に探し、無ければ通常の Codex CLI(0.154 以上)を使う。短命な `codex exec` の親では返信をブリッジの通話履歴に保持し、次に親がプロンプトを受けた時、同期 `UserPromptSubmit` hook が取得してモデルへ渡す。親 CLI の終了時に子プロセスも止まる Windows でも、この経路は常駐プロセスを必要としない。GrokBot メンバーは従来どおり公開 MCP に接続し、
返信には `from_party="member"` を使う。
対象は通常の Codex 親タスク。native sub-agent への自動配送は未対応。
有効化する端末には、まず `call-bridge` または `grokbot-bridge` を HTTP MCP として登録する(下の Codex の登録例を参照)。
`call-bridge-setup enable` を実行するシェルで、その登録の Bearer token 環境変数を利用可能にしておく。
```bash
python -m pip install git+https://github.com/kitepon/grokbot-bridge.git
npm install -g aiterm-steer-delivery@^0.1.5
call-bridge-setup enable
# Codex を完全終了して再起動
call-bridge-setup status
```
`uv tool` で入れた端末は、`pip install` の代わりに `uv tool install git+https://github.com/kitepon/grokbot-bridge.git` を使う。
既存の端末を更新する手順:
```bash
uv tool upgrade grokbot-bridge # pip の場合は pip install -U git+https://github.com/kitepon/grokbot-bridge.git
npm install -g aiterm-steer-delivery@^0.1.5
call-bridge-setup enable
# Codex を完全終了して再起動
call-bridge-setup status
```
`enable` をやり直すと、旧版が登録した本製品の `PostToolUse`/`Stop` hook を外し、パッケージの Steer hook に入れ替える。
Windows で Steer hook を使うには PowerShell 7(`pwsh.exe`)が要る。Codex は hook を PowerShell 7 で起動する。
`enable` は既存の URL と token 環境変数名を読み、その MCP 登録をローカル MCP に切り替える。
Node 製 Codex の場合は実行中の Node の絶対パスも製品設定へ保存し、MCP の PATH が狭い環境でも返信配送用 App Server を起動する。CLI や Node を移動した後は `enable` を再実行する。
認証値は製品の state directory(既定は `~/.grokbot-bridge`)の `auth.json` に本人だけが読める権限で保存し、Codex が環境変数を継承しない場合もローカル MCP が使用する。Git や Codex 設定には書かない。`disable` はそのファイルを削除する。
ローカル MCP と返信の見張りは、問い合わせのたびに `auth.json` を読み直すので、ファイルを差し替えれば動いたまま新しいトークンへ移る。読めない状態が約30秒続いた時だけ見張りを止める。ただし環境変数(既定は `CALL_BRIDGE_TOKEN`)が設定されていればそちらが優先され、プロセスの起動時の値から変わらない。トークンを入れ替える端末では、MCP を環境変数なしで起動し、`auth.json` で渡す。
また、`codex exec` の親へ返信を渡す本製品専用の `UserPromptSubmit` hook を登録・承認し、
`aiterm-steer-delivery codex setup enable` でパッケージの Steer hook を登録する。他製品の hook は保持する。
旧版が登録した本製品の `PostToolUse`/`Stop` hook は外す。
hook を外したり置き換えたりして他の hook の位置が動く時は、Codex が位置ごとに持つ承認をその hook の新しい位置へ写す。そのため、後ろにある他製品の hook の承認が外れることはない。
`aiterm-steer-delivery` の場所(`AITERM_STEER_DELIVERY` か PATH)と Node の絶対パスは製品設定へ保存し(npm のシムではなく、同じ場所にあるパッケージの `dist/cli.js` を Node で直接起動する。`enable` と `status` は 0.1.5 以上かを確かめる)、パッケージへ渡す識別情報は state directory の `steer-profile.json` に置く。
設定変更前の `hooks.json` と `config.toml` は製品の state directory に tar で保存する。
元の HTTP MCP へ戻すときは `call-bridge-setup disable` を実行して Codex を再起動する。
`aiterm-steer-delivery` が消えていても `disable` は call-bridge の設定を元へ戻す。その場合、Steer hook を外せなかったことを結果の `warning` で知らせる。
`BRIDGE_TOKEN_MISSING` が出た場合は、既存の HTTP MCP 登録に指定した環境変数を
`enable` を実行するシェルへ渡し、`call-bridge-setup enable` を再実行する。
シェルに値があっても、起動済みの Codex MCP プロセスがその値を継承するとは限らない。
`enable` 後は Codex を完全終了して再起動する。`status` は登録と hook の状態を確認する。
結果の `steer` はパッケージの Steer hook の状態(`ready`/`restart_required`/`disabled`)。
`enable` は導入前から動く Codex の PID と生成時刻を記録し、そのプロセスが残る間は
`restart_required` を返す。`call_open` も同じ親プロセスへの配送を拒否する。
完全終了・再起動後に `call-bridge-setup status` の `ready` を確認する。
返信は `session_id` と `seq` で順番に処理する。配送結果はローカル MCP の `call_info` に
`parent_delivery` として表示する。送信結果が不明なときは自動再送せず `unknown` と記録する。
`submitted` は公式キューの受付、`injected` は `codex exec` のプロンプト hook が返信を出力したことを示し、いずれも親 AI の読了を示さない。hook がキューから
取り出し中なら `sending`、取り出しの中断や出力失敗なら `unknown` と
`CODEX_HOOK_DELIVERY_UNCONFIRMED` を表示する。hook が受け取らず待機中の Codex が
先に処理した入力の所有記録は、次の hook 実行時に整理する。
返信本文は bridge に残り、手動で `call_poll` から確認できる。
継続型 Codex のローカル MCP は再起動後に進行中の通話の受信を再開する。`codex exec` の返信は親の次のプロンプトまで通話履歴に残る。
現在の自動配送対象は Codex 親。Claude Code/Cursor の直接 HTTP 接続と手動 `call_poll` は従来どおり使える。
GrokBot宛てだけの経路(通常。`courier`):
```text
Local agent ──call_directory──▶ grokbot-bridge (phone book)
Local agent ──call_open──▶ grokbot-bridge (session only, nobody is woken)
Local agent ──call_send──▶ grokbot-bridge (text stored, sender preface added, status=queued)
box courier ──claim──▶ grokbot-bridge
box courier ──POST 127.0.0.1/api/sendPrompt──▶ Grok Bot member
box courier ──call_receipt──▶ grokbot-bridge (submitted / failed / unknown)
Grok Bot member ──call_send──▶ grokbot-bridge (reply stored for the local side)
```
`marian` にしているときだけ、`call_send` は本文なしの `session.opened` をマリアンへ送り、相手は `call_poll` で本文を読む。配達役はそのメッセージを受け取らない。
`session.opened` uses schema `grokbot.call.v0` and never carries the caller's text. The webhook secret is an `Authorization` header, never a payload field.
```json
{
"schema": "grokbot.call.v0",
"event": "session.opened",
"session_id": "...",
"status": "ringing",
"member_name": "...",
"member_agent_id": "...",
"local_id": "...",
"local_label": "...",
"purpose": null,
"mcp_url": "https://call.kitepon.dev/mcp",
"created_at": "..."
}
```
## MCP tools
| Tool | Role |
|------|------|
| `call_directory` | Phone book, built on that call from live seat profiles. Use it to choose who to open |
| `call_open` | Create session (local → member). Does not wake anyone |
| `call_send` | Deliver to that member. GrokBot goes to the box courier; BellTeam uses its UNIX socket. Local sends accept `sender_name` (and require `sender_team` when the token is not bound). A member reply is stored and is also delivered by UNIX socket when the caller is in BellTeam. Notices use `reply_required=false` |
| `call_poll` | Fetch new messages for your party |
| `call_list` | List / filter sessions |
| `call_hangup` | End the call |
| `call_info` | Session details, `local_delivery` (what the terminal did with member messages) and `grokbot_delivery` (what the courier did with `sendPrompt`) |
| `call_history` | Both parties' messages in order, without marking them delivered |
| `call_receipt` | Terminal reports what it did with a member message. The box courier reports `sendPrompt` as `submitted`, `failed`, or `unknown` |
Also exposes a small REST surface under `/v0` (same auth) and open `/health`.
## Phone directory
Clients only call `call_directory` (or `GET /v0/directory`). The server reads Grok Bot seat profiles and, when configured, BellTeam's directory **on that request**. Changing a profile shows up on the **next** call. Each member has `system` (`grokbot` or `bellteam`) and its existing `id`. One directory can remain available when the other fails.
For GrokBot seats, the source of truth is each seat’s **profile** (`name`, `title`, `description`) — used as-is (e.g. ラピ → title `インフラ統括`, `description` → `role`). There is **no** “may call” flag. Each member built from a profile also includes `id`: the seat directory name, which is the GrokBot agent id (`profile.json` itself has no id field). A GrokBot-targeted `call_send` resolves `member_name` to that id. The box courier sends it as `agentId`. In `marian` mode the same id is `member_agent_id` on the `session.opened` ring. A remote directory passes `id` or `agentId` through. A `directory.json` entry without an id cannot be relayed to GrokBot.
Lookup order:
1. **`CALL_BRIDGE_DIRECTORY_UNIX`** (preferred in production). HTTP GET over an `AF_UNIX` socket. The HTTP path is `CALL_BRIDGE_DIRECTORY_UNIX_PATH` (default `/v0/directory`). On main-server the container mounts the socket's parent directory at `/run/dirlive`, and the socket path is `/run/dirlive/dirlive.sock`.
2. **`CALL_BRIDGE_DIRECTORY_URL`** — plain HTTP GET, only if the unix socket is unset or that GET fails.
3. **Courier push** — the book the box courier last built from `profile.json` and PUT to `/v0/grokbot/directory`, while `pushed_at` is within `CALL_BRIDGE_DIRECTORY_PUSH_MAX_AGE` seconds (default 120). A fresh push does not page the operator about the unix socket.
4. **Local profiles**, only after the remotes and the push missed: `CALL_BRIDGE_AGENTS_ROOT` if set and that directory exists, otherwise `/home/box/agent-data/agents` when the env var is unset and the path exists. This is for running call-bridge on the Grok Bot box itself. A copied agents tree on main-server is not the primary path.
5. **`directory.json`** — last-resort snapshot when the remotes failed and no local profile tree is available.
A live read reports `source: agent-profiles` and `agents_root`. A unix read also includes `directory_unix`; a URL read includes `directory_url`. The snapshot reports `source: directory.json` and does not claim to be a live profile read. If a remote was configured and failed before the snapshot was used, the response includes `directory_unix_error` and/or `directory_url_error`.
Prod wiring (operated outside this repo): the Grok Bot box runs `scripts/directory_live_server.py` on `127.0.0.1:18765`. SSH reverse-forwards that port to main-server (`ssh -R 127.0.0.1:18765:127.0.0.1:18765`). A host `socat` listens on a unix socket and dials `127.0.0.1:18765`. Compose mounts that socket's parent directory (`./dirlive:/run/dirlive`) so a recreated socket is visible without recreating the container. Set `CALL_BRIDGE_DIRECTORY_UNIX=/run/dirlive/dirlive.sock`. If `CALL_BRIDGE_DIRECTORY_UNIX_HOST` is set, point it at the directory (the default is `./dirlive`), not at the socket file.
```bash
# On the Grok Bot box (profiles live here):
python scripts/directory_live_server.py
# {"ok": true, "listen": "http://127.0.0.1:18765/v0/directory", ...}
# MCP clients — this is the whole interface
call_directory()
call_directory(query="インフラ")
# REST
curl -sS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:18910/v0/directory?q=ラピ"
```
`scripts/sync_directory_from_agents.py` only writes an optional `directory.json` fallback. It is not how the book stays fresh, and nothing needs to run it after a profile edit.
## 箱の配達役
Grok Bot のメンバーを起こす処理は、箱の中で動かす。橋(call.kitepon.dev)は箱の外にあり、箱の `127.0.0.1` には届かない。配達役は橋へ外向きに接続し、新着を1件ずつ受け取り、箱のゲートウェイ `POST http://127.0.0.1:<port>/api/sendPrompt` を叩く。`port` と `token` は箱の `/home/box/agent-data/gateway.json`(`port` と `token`。`host` が `0.0.0.0` でも接続先は `127.0.0.1`)から読む。ファイルの中身は表示しない。本文は次の形で、`clientNonce` は通話と seq から決まる同じ値を使う。
```json
{"agentId":"<席のid>","clientNonce":"<安定した値>","prompt":"<保存された本文>","automationWriteProvenance":"untrusted"}
```
結果は `call_receipt` で橋へ返す。`submitted` はゲートウェイが受け付けたこと、`failed` は届いていないこと、`unknown` は届いたか分からないこと。`unknown` は再送しない。`failed` は同じ `clientNonce` で、最大3回まで取り直す。ジャーナルは `sendPrompt` の前に書く。途中で止まったときは、呼んだかもしれないものは `unknown` として報告し、もう一度は送らない。
電話帳は、配達役が席の `profile.json` を読んで橋へ渡す。橋は UNIX ソケットが届かないとき、その一覧が新しければ使う。ソケット側の `scripts/directory_live_server.py` も、要求のたびに同じプロフィールから組み立てる。どちらも手で `directory.json` を更新する必要はない。
箱には cron も systemd のユーザー単位もない。常駐は tmux のセッションで行う。再起動のあとは、同じコマンドをもう一度実行する。
橋側のトークンは、所属 `grokbot` に `courier: true` を付け、id は付けない(`scripts/issue_token.py --name grokbot-courier --system grokbot --courier`)。メンバー用の grokbot トークンには `courier` を付けない。そのトークンでは新着を受け取れない。トークン本体は箱のファイルに1行で置き、権限は 600 にする。コマンドラインやこの README には書かない。
```bash
# 箱。このリポジトリのディレクトリで。
install -d -m 700 /home/box/agent-data/call-bridge-courier
install -m 600 /dev/null /home/box/agent-data/call-bridge-courier/bridge.token
# bridge.token に、橋の grokbot 所属トークンを1行で書く。echo は履歴に残るので使わない。
export CALL_BRIDGE_URL=https://call.kitepon.dev
export CALL_BRIDGE_TOKEN_FILE=/home/box/agent-data/call-bridge-courier/bridge.token
python3 scripts/box_courier.py --once
# 終了コード 0 かつログの pass ok exit=0 … pushed=1 で、橋への接続と電話帳の供給まで通っている。
# exit=2 は橋(inflight / claim / receipt)、exit=3 は電話帳、exit=4 はその両方。
# exit=1 は起動前(ロック、gateway.json、橋のトークン)。401 ならトークンが違う。トークンはログに出さない。
tmux new-session -d -s call-bridge-courier -c "$PWD" \
'export CALL_BRIDGE_URL=https://call.kitepon.dev CALL_BRIDGE_TOKEN_FILE=/home/box/agent-data/call-bridge-courier/bridge.token; exec python3 scripts/box_courier.py'
tmux ls
# ログ: tmux attach -t call-bridge-courier 離れるときは Ctrl-b のあと d
# 止める: tmux kill-session -t call-bridge-courier
```
2つ目を起動すると、ロックを持っている方だけが残り、もう一方は `another courier is already running` で終わる。
橋の切替は `CALL_BRIDGE_GROKBOT_DELIVERY`。既定は `courier`。配達役を動かす前にこの版だけを置くときは `marian` にして、今の起こし方を維持する。配達役を確かめたあと `courier` にする(または変数を外す)。`marian` の間に保存したメッセージは配達役が受け取らない。`courier` で `queued` のまま残っているメッセージは、配達役が動き続けている間に届く。両方を同時に同じメッセージへは使わない。
本番へ切り替える前に、箱で `--once` が通り、テスト用の1通が `call_info` の `grokbot_delivery` で `submitted` になり、同じ本文がもう一度席へ出ていないことを見る。`unknown` になったものは再送せず、通話の記録を見てから人が対応する。
## Quick start
```bash
cp .env.example .env
# set CALL_BRIDGE_TOKEN to a long random string
docker compose up -d --build
curl -sS http://127.0.0.1:18910/health
```
MCP endpoint: `http://127.0.0.1:18910/mcp`
Auth: `Authorization: Bearer <token>` on `/mcp` and `/v0/*` (`/health` is open). Without any token setting, `/mcp` and `/v0/*` are also open; use this only for local development.
### 発信者の本人確認
`CALL_BRIDGE_TOKENS_FILE` を設定すると、トークンごとに所属を結び付ける。ファイルにはトークン本体ではなく SHA-256 を書く(`printf %s "$TOKEN" | sha256sum`)。
```json
{"tokens": [
{"name": "bellteam", "sha256": "<hex>", "system": "bellteam", "caller_id_header": true},
{"name": "grokbot", "sha256": "<hex>", "system": "grokbot"},
{"name": "macbook", "sha256": "<hex>", "system": "local"},
{"name": "ops", "sha256": "<hex>", "ops": true}
]}
```
- `system` 付きのトークンは、その所属の当事者としてだけ動ける。`call_open` の `local_system` は所属と一致しなければならない。
- `call_send`・`call_poll`・`call_hangup` は、通話の `local`/`member` のうち、接続の所属に当たる側だけを受け付ける。`call_info`・`call_list` も当事者の通話だけを返す。違反は `error=forbidden`(REST は 403)。
- `call_open` で `local_system` を省くと、所属に結び付いた接続ではその所属を使う。旧トークンと開発モードでは今までどおり `local`。
- `caller_id_header: true` のトークンは、基盤が `X-Call-Bridge-Caller-Id` で発信者の ID を付けられる。付いた接続は、その ID の通話だけを扱える。BellTeam は Bot ごとに付ける。BellTeam トークンでヘッダーを付けない接続は、BellTeam 全体として扱う。GrokBot は1つの接続設定を共有するので所属単位。許可のないトークンにこのヘッダーがあると 403。旧トークンに付いたヘッダーは無視する。
- `system` のない項目は全権で、`ops: true` が必要。`call_hangup(by_party="ops")` は `ops` のトークンと旧トークンだけが使える。
- `ops`・`caller_id_header`・`courier` は真偽値、`name`・`sha256`・`system`・`id` は文字列で書く。`courier: true` は `system: grokbot` だけで、`id` は付けない。型の違い、同じ `sha256` の重複、項目0件、読めないファイル、壊れた JSON は、1行のエラーで起動を止める。
- 移行中は `CALL_BRIDGE_TOKEN` も全権の旧トークンとして受け付ける。対応ファイルに項目がある間は、旧トークンが使われると5分に1回警告を記録する。`CALL_BRIDGE_TOKENS_FILE` がなければ、今までと同じ動きになる。
#### 切り替え手順
1. 所属ごとにトークンを作り、配る先と方法を決める(GrokBot 側はマリアン、BellTeam はトロニーが受け持つ)。対応ファイルにはハッシュだけを書く。発行はブリッジのホストで、リポジトリの `scripts/issue_token.py` を使う(`src/call_bridge/auth.py` と同じ規則で結果を確かめるので、リポジトリの中から動かす)。
- トークン本体は `--out` のファイル(権限600で作る)か、`--out -` の標準出力にだけ出す。
- 名前と `--id` は前後の空白を落とし、空なら止まる。書き込む前に、できあがる対応ファイル全体をブリッジの読み込み規則で確かめる。
- 同じ名前や既存の `--out` は、`--replace` がない限り止まる。
- 失敗しても、それまでのトークンは使える。対応ファイルは新しいトークンを渡し終えてから置き換え、`--out` の置き換えに失敗したら元に戻す。`--out -` は標準出力へ書き終えてから登録する。
- 対応ファイルはロックを取り、一時ファイルから置き換えて、ディレクトリも fsync する。止まった時でも、隣に `tokens.json.lock` は作られる。
```sh
mkdir -p auth
python3 scripts/issue_token.py --tokens-file auth/tokens.json --name bellteam --system bellteam \
--caller-id-header --out /path/to/headers --format header
ssh main-server 'cd /home/kite/call-bridge && python3 scripts/issue_token.py \
--tokens-file auth/tokens.json --name grokbot --system grokbot --out -' > grokbot.token
```
対応ファイルは、発行してからマウントする。ファイルそのものではなく `./auth` ディレクトリを読み取り専用でマウントする(`docker-compose.yml` の例)。ファイルがないまま compose でファイルをマウントすると、Docker がそこにディレクトリを作ってしまう。ファイルのマウントだと、スクリプトが置き換えた後も古い実体を見続ける。どちらにしてもブリッジは起動時にしか読まないので、発行や入れ替えのたびに再起動する。
2. 対応ファイルを読み取り専用でマウントし、`CALL_BRIDGE_TOKENS_FILE` を設定して再起動する。旧トークンは残す。この時点では誰も新しいトークンを使っていないので、動きは変わらない。
3. 各基盤の接続設定を新しいトークンへ切り替える。BellTeam は `X-Call-Bridge-Caller-Id` を付ける版を先に入れておく。
4. 切り替えた基盤から、`local_system` を省いた発信と返信を1往復ずつ試す。
5. 切り替え前に開いた通話は、発信者の所属が `local_system='local'` のまま残っていることがある。新しいトークンでは当事者と一致せず `forbidden` になる。旧トークンを止める前に、進行中の通話を確かめて終わらせる。
```sh
sqlite3 data/calls.db "SELECT session_id, local_system, local_id, member_system, member_id, status FROM sessions WHERE status IN ('ringing','open')"
```
6. 旧トークンの警告が出なくなったら、`CALL_BRIDGE_TOKEN` を外して再起動する。この修正より前の版のローカル MCP は、返信の見張りを始めた時のトークンを通話が終わるまで使い続ける。その見張りは旧トークンを止めると 401 で `failed` になるので、`local.sqlite` の `subscriptions` で `active` に戻し、環境変数なしで `python -m call_bridge.exec_watcher <session_id>` を起動して付け直す。`after_seq` は残るので返信は取りこぼさない。
### Client examples
**Cursor** (`~/.cursor/mcp.json`):
```json
{
"mcpServers": {
"grokbot-bridge": {
"url": "https://your-host.example/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
```
**Claude Code**:
```bash
claude mcp add --transport http grokbot-bridge https://your-host.example/mcp \
--header "Authorization: Bearer YOUR_TOKEN"
```
**Codex**:
```bash
export CALL_BRIDGE_TOKEN=YOUR_TOKEN
codex mcp add grokbot-bridge --url https://your-host.example/mcp \
--bearer-token-env-var CALL_BRIDGE_TOKEN
```
**Grok Bot**: add the remote MCP URL with the same Bearer header in the account connectors.
Put a reverse proxy (Caddy, nginx, Cloudflare Tunnel, …) in front for HTTPS.
## Config
| Env | Default | Meaning |
|-----|---------|---------|
| `CALL_BRIDGE_TOKEN` | _(unset)_ | Bearer token. Set it for production; if unset, MCP and REST are open for local development |
| `CALL_BRIDGE_TOKENS_FILE` | _(unset)_ | 所属に結び付けたトークンの対応ファイル(上記)。コンテナでは読み取り専用でマウントする |
| `CALL_BRIDGE_HOST` | `0.0.0.0` | Bind host |
| `CALL_BRIDGE_PORT` | `18910` | Bind port |
| `CALL_BRIDGE_DB` | `data/calls.db` | SQLite path |
| `CALL_BRIDGE_ALLOWED_HOSTS` | `127.0.0.1:*,localhost:*` | Host header allowlist |
| `CALL_BRIDGE_ALLOWED_ORIGINS` | `http://127.0.0.1:*,http://localhost:*` | Origin allowlist |
| `CALL_BRIDGE_DIRECTORY_UNIX` | _(unset)_ | On each `call_directory`, HTTP GET over this `AF_UNIX` socket. Preferred prod source (`/run/dirlive/dirlive.sock`) |
| `CALL_BRIDGE_DIRECTORY_UNIX_PATH` | `/v0/directory` | HTTP path on that socket |
| `CALL_BRIDGE_DIRECTORY_URL` | _(unset)_ | HTTP GET used when the unix socket is unset or fails |
| `CALL_BRIDGE_DIRECTORY_URL_AUTH` | _(unset)_ | Bearer token sent on the unix and URL GETs (raw token or `Bearer …`) |
| `CALL_BRIDGE_DIRECTORY_URL_TIMEOUT` | `2.5` | Seconds for each remote GET |
| `CALL_BRIDGE_AGENTS_ROOT` | `/home/box/agent-data/agents` if that directory exists and the env var is unset | Local `profile.json` tree, used only when configured remotes fail (or none are set). When set, only that path is used |
| `CALL_BRIDGE_DIRECTORY` | `./directory.json` | Last-resort GrokBot snapshot file |
| `CALL_BRIDGE_BELLTEAM_SOCKET_HOST` | `./bellteam` | Host directory mounted at `/run/bellteam` in Compose |
| `CALL_BRIDGE_BELLTEAM_UNIX` | _(unset)_ | BellTeam socket inside the container, e.g. `/run/bellteam/bellteam.sock` |
| `CALL_BRIDGE_GROKBOT_DELIVERY` | `courier` | `courier`: the box courier delivers with `sendPrompt`. `marian`: ring with `session.opened` and the member reads via `call_poll`. Do not use both for the same messages |
| `CALL_BRIDGE_DIRECTORY_PUSH` | `data/grokbot-directory.push.json` | Phone book last pushed by the box courier |
| `CALL_BRIDGE_DIRECTORY_PUSH_MAX_AGE` | `120` | Seconds a courier push stays usable when the unix socket is down |
| `CALL_BRIDGE_WAKE_WEBHOOK_URL` | _(unset)_ | Switchboard webhook. Used for `bridge.link_down`, and for the `marian` ring (`session.opened`, no message body). In `marian` mode an empty URL fails the send and does not store. A down GrokBot directory UNIX socket posts `bridge.link_down` at most once per 60 seconds, unless a fresh courier push already supplied the book |
| `CALL_BRIDGE_WAKE_WEBHOOK_AUTH` | _(unset)_ | `Authorization` header for those POSTs. Never placed in the payload |
| `CALL_BRIDGE_RERING_SECONDS` | `600` | A local `call_send` rings again when the member's oldest unread message is at least this old |
| `CALL_BRIDGE_PUBLIC_MCP_URL` | `https://call.kitepon.dev/mcp` | MCP URL included in `session.opened` |
Compose sets `extra_hosts: ["host.docker.internal:host-gateway"]` so that hostname resolves inside the container. Recreate the container after changing `.env`. On the Grok Bot box, restart `scripts/directory_live_server.py` from this revision so live directory members include `id`.
`CALL_BRIDGE_SWITCHBOARD_WEBHOOK_URL` and `CALL_BRIDGE_SWITCHBOARD_WEBHOOK_AUTH` are aliases for the webhook URL and auth value (`session.opened` and `bridge.link_down`).
## Stack
- Python 3.12 in Docker (package requires Python 3.11+) / FastMCP streamable HTTP (`mcp>=1.2,<2`)
- SQLite for sessions + messages
- Docker Compose for deploy
## License
MIT © kitepon.dev
### 実行不要となった旧返信で配送が止まった時
Codex の旧返信が `CODEX_DELIVERY_DUPLICATE` または本文投入前の `CODEX_REQUEST_TIMEOUT: initialize が時間内に返りませんでした` で止まり、その依頼を再実行してはいけない場合は、端末の管理者が、依頼元と失効対象を確認してから `call-bridge-recover` を使える。
```bash
call-bridge-recover --session <通話UUID> --seq <旧返信のseq> --reason '別経路で完了したため失効' \
--expected-thread <現在のCodex会話UUID> --expected-after <現在のafter_seq> --expected-hold <現在のhold_seq>
```
複数の旧返信を失効させる場合は `--seq` を昇順で繰り返す。`after_seq` より後から最後に指定した seq までの全 member 返信を、漏れなく指定する。新しい返信を失効対象へ混ぜない。
この命令は、その通話の見張りへ鍵の解放を頼み、公式キューが最初の1ページで空(data=[]、nextCursor=null)であること、処理中の hook claim が無いこと、通話と予約が確認中に変わっていないことを確かめる。条件が揃わなければ失効しない。送信・キュー削除・hook実行は行わず、pending/settledにも触れない。キューが非空・複数ページ・不正な応答なら適用しない。同じIDをほかの送信元が新規投入しないことも、実行前に確認する。
失効対象の番号・理由・時刻は端末台帳の `superseded_replies` に保存し、元の配送状態・エラー・予約時刻はそのまま残す。サーバーへ成功や取得済みの receipt は送らない。以後の `call_info.parent_delivery.superseded_replies` に失効記録を表示し、後続の返信は通常の受け取り処理に戻す。現在版の受け取り処理は失効IDを再予約しても送信しない。旧MCPはafter_seqを読み直す通常経路で旧返信を飛ばすが、旧コードのreserveを直接呼ぶ操作まで防ぐものではない。これは旧依頼の失効であり、過去の未実行や配送成功の証明ではない。古い MCP には追加した表示項目がないので、更新した受け取り処理・CLIで確認する。
### Claude channel入口timeout後の照合
新しい予約は送信先channelを保持する。入口timeoutなどでunknown/sendingになった場合、receiverは保存されたchannelの公的emittedを確認できた時だけ再送無しで通常配送へ戻す。queued(hookが未取得)やstate無しの行は復帰の対象外で、unknownのまま残す。元error/初回at/IDは保持し、startedはhook出力の確証であり、本人の業務完了を表さない。
受領通知のHTTP送信に失敗した場合は、端末台帳へ通知を保存し、通常の受け取り係が再送する。本文は再送しない。閉じた通話も通知だけを送る。導入前に失われたstarted通知は、管理者が `python -m call_bridge.channel_recover --receipt --session <UUID> --seq <番号> --expected-current <現channelUUID> --expected-owner <Claude会話UUID> --expected-after <cursor>` で復旧できる。元のsubmitted/confirmed予約と同じchannelの公的emittedを確認し、通知だけを保存する。unknown、別channel、出力未確認、失効済み、照合中の台帳変更は拒否し、元の配送行・cursor・本文は変更しない。
旧台帳でchannelが欠ける場合は自動推測しない。管理者が初回送信先と現在の本人を確かめた上で `python -m call_bridge.channel_recover --session <UUID> --seq <番号> --expected-current <現channelUUID> --expected-owner <Claude会話UUID> --source-channel <元channelUUID> --expected-after <cursor>` を使う。公的channel集合とownerの前後一致、同ownerで唯一のemitted、他の配送状態無し、session lockと全台帳snapshot一致を条件に、送信先の結合だけを保存する。unknown/error/atを保持し送信しない。以後receiverが固定先の公的emittedを再確認して通常状態へ戻す。読取失敗・曖昧なowner・関連状態あり・通話変更なら拒否する。旧MCPの予約はchannelを保存しないため、更新したreceiverが通常のsession排他を取得して扱う。
管理者が旧返信を実行不要と明示判断した場合は、`python -m call_bridge.channel_recover --supersede --session <UUID> --seq <cursorの次の番号> --expected-current <現channelUUID> --expected-owner <Claude会話UUID> --expected-after <cursor> --expected-at <元予約at> --reason <判断者・失効理由・元作用不明>` で管理上の失効を記録できる。対象は既知channelに結合された単独のunknown/DELIVERY_PREVIOUSLY_STARTEDで、未取得・同owner全channelの現在状態がnull・他の未確定予約なし・session排他と全snapshot一致を要求する。Owner指示の新本人が通常の通話操作で届け先を変更した後は、`--source-channel <元予約channelUUID>` を併記する。元予約のchannel一致に加えて、旧本人と新本人の全Claude channelに当該配送IDの現在状態が無いことを前後で検証する。続く未予約の返信も明示的に取り消した場合は `--through-seq <最後の取消番号>` を指定する。範囲内の全番号が未取得のmember返信であることを確認し、失効記録とcursorを同じtransactionで更新する。nullは過去未実行の保証ではない。元配送行のunknown/error/at/confirmedを一切変更せず、専用失効記録とcursorを原子的に進め、後続だけを通常配送する。旧本文の送信やreceipt報告は行わない。管理上の失効を本人受領や旧仕事の実行完了と扱わない。
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessOutdated data