はじめに:セッションが増えると「伝言」がボトルネックになる
当社(合同会社ジョインクラス)は、launchdの定期ジョブ17個とClaude Codeで会社の運用をほぼ自動で回しています。出版・マーケ・開発の仕事をそれぞれ別のClaude Codeセッションに任せているので、ある時期からセッション同士の伝言がボトルネックになりました。
たとえば「開発セッションで直したバグを、マーケのセッションに伝えてリリースノートを書かせる」といった作業です。以前は僕が両方のターミナルを行き来して、内容をコピペしていました。自動化率98%をうたう会社で、この部分だけ人間が手で運んでいたわけです。
この記事では、Claude Codeのクロスセッションメッセージング(ListAgents / SendMessage)でセッション同士をつなぐ方法と、対話セッションがない無人ジョブ(launchd)からも同じことをやる方法を、当社で実際に動いているスクリプトをもとに紹介します。
全体像
| 経路 | 送り手 | 受け手 | 使うもの |
|---|---|---|---|
| A | 対話セッション | 同じマシンの別の対話セッション |
ListAgents → SendMessage
|
| B | 対話セッション | 自分が起動したサブエージェント |
Agent の戻り値のID → SendMessage
|
| C | launchdジョブ(無人) | 新しく起動するheadlessセッション |
claude -p + ファイル受け渡し |
AとBはClaude Codeに組み込まれている機能です。Cは当社で書いたシェルスクリプトです。
経路A:対話セッション同士でメッセージを送る
手順1:宛先を調べる
送る側のセッションで、自然言語でこう頼みます。
> 今このマシンで動いている他のClaudeセッションを一覧して
Claudeが ListAgents ツールを呼び、宛先の一覧を返します。表示されるのは自分が起動したサブエージェント、同じマシンで動いている別のセッション、(接続していれば)クラウドのセッションで、それぞれ種別のラベルが付きます。SendMessage の宛先には、この一覧に出た名前をそのまま使います。
手順2:送る
> one-ceo-marketing セッションに「v1.4.2 で note 投稿の改行崩れを修正した。
修正内容は git log -1 を参照。リリースノートの下書きを
.company/departments/marketing/ に作って」と送って
内部では次のようなツール呼び出しになります。
{
"to": "one-ceo-marketing",
"message": "v1.4.2 で note 投稿の改行崩れを修正した。修正内容は git log -1 を参照。リリースノートの下書きを .company/departments/marketing/ に作って"
}
落とし穴
-
同じ名前のセッションが2つあると送れません。 一覧の名前のあとに付く
[ref]まで含めて指定します。リポジトリ名でセッションの名前を決めていると、同じリポジトリでターミナルを2つ開いたときにこれが起きます。 - クラウドのセッションは返信できません。 受け取ることはできますが、送り返す手段がまだありません。「終わったら返事して」と書いても返事は来ないので、結果は相手のトランスクリプトを見て確認します。
- 本文はファイルのパスだけにします。 当社のCLAUDE.mdでは「ファイルの中身は渡さず、パスだけ渡す」と決めています(Thin Orchestratorの原則)。中身を本文に貼ると、受け手のコンテキストも同じだけ消費されます。
経路B:サブエージェントの続きを頼む
Agent ツールで起動したサブエージェントは、作業が終わってもIDで呼び戻せます。新しく Agent を呼ぶとコンテキストがゼロからになりますが、SendMessage ならそれまでの調査内容を引き継いだまま続きを頼めます。
> さっきの ai-ceo-publisher エージェントに、
第3章だけ図表を追加するよう追加指示して
この使い分けで、当社の出版フローでは章ごとの修正のたびに市場調査をやり直すことがなくなりました。
経路C:無人ジョブからセッションに仕事を渡す
launchdから動くジョブには、話しかける対話セッションがありません。そこで「送る側はファイルに書く、受ける側は claude -p で新しいセッションを起動して読む」形にしています。
土台:claude -p の共通ラッパー(本番で稼働中)
当社のジョブはすべて、次のラッパーを通して claude を呼んでいます。
#!/bin/bash
# claude を無人実行するときの共通ラッパー。実コストを記録する。
#
# 使い方:
# source /Users/kyoagun/workspace/one-ceo/.company/scripts/lib/claude-run.sh
# RESULT=$(claude_run dept-dev --effort low -p "…")
#
# 標準出力には応答テキストだけを出すので、従来の `claude -p` と差し替えられる。
# 注意: --bare は認証を読まないため "Not logged in" で失敗する。使わない。
_CLAUDE_RUN_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
claude_run() {
local label="$1"; shift
claude --output-format json \
--fallback-model claude-sonnet-5 \
--max-budget-usd 3 \
"$@" 2>/dev/null \
| python3 "$_CLAUDE_RUN_LIB/record-cost.py" "$label"
}
--output-format json で受け取ったJSONを record-cost.py に渡し、応答テキストは標準出力に、実際にかかったコスト(total_cost_usd)は cost-tracker.json に記録します。
try:
d = json.loads(raw)
except json.JSONDecodeError:
sys.stdout.write(raw) # JSONでなければ素通しする
return 0
sys.stdout.write(d.get("result") or "")
if d.get("is_error"):
print(f"record-cost: {label} — {str(d.get('result'))[:160]}", file=sys.stderr)
受け渡し:ファイルのメールボックス
ラッパーの上に、宛先ごとのメールボックスを置きます。次の2つの関数は当社の運用をもとに整理した最小構成で、コピペすればそのまま動きます。
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/lib/claude-run.sh"
MAILBOX_ROOT="${MAILBOX_ROOT:-$HOME/.claude-mailbox}"
# 送る: mailbox_send <宛先> <本文>
mailbox_send() {
local to="$1" body="$2"
mkdir -p "$MAILBOX_ROOT/$to/new"
local tmp; tmp=$(mktemp "$MAILBOX_ROOT/$to/.tmp.XXXX")
printf '%s\n' "$body" > "$tmp"
# 書き終わってから mv する(受け手が途中までしか書かれていないファイルを読まないように)
mv "$tmp" "$MAILBOX_ROOT/$to/new/$(date +%Y%m%d%H%M%S)-$$.msg"
}
# 受ける: mailbox_drain <宛先> <claude_runに渡す追加引数...>
mailbox_drain() {
local to="$1"; shift
local dir="$MAILBOX_ROOT/$to"
mkdir -p "$dir/new" "$dir/done"
for msg in "$dir"/new/*.msg; do
[ -e "$msg" ] || return 0 # 空なら何もしない(課金ゼロ)
claude_run "mailbox-$to" "$@" -p "$(cat "$msg")" >> "$dir/replies.log"
mv "$msg" "$dir/done/"
done
}
"$@"
使い方は次のとおりです。
# 送る側(例:夜間のテストジョブ)
./mailbox.sh mailbox_send marketing "テストが3件失敗。.company/departments/dev/error-log.md を読んで要約して"
# 受ける側(launchdで15分ごとに起動)
./mailbox.sh mailbox_drain marketing --effort low --allowedTools "Read,Write"
この設計にしている理由
-
空のときは
claudeを起動しない。 当社で計測したところ、「2+2」を聞くだけでも1回 $0.27〜0.47 かかっていました。中身はClaude Code側の固定のキャッシュ作成(約41,000トークン)で、自社のCLAUDE.mdやスキルは5,800トークンしかありません。減らせるのは呼び出し回数だけなので、[ -e "$msg" ] || return 0が一番のコスト対策になります。 -
mvで原子的に書き込む。 受ける側のジョブが書きかけのファイルを読まないようにするためです。 -
--bareは使わない。 認証情報を読まないので、無人実行では "Not logged in" で必ず失敗します。当社で実際にこれで止まったことがあり、ラッパーのコメントに残しています。
動作確認
export MAILBOX_ROOT=/tmp/mbtest
./mailbox.sh mailbox_send test "OKとだけ返して"
ls /tmp/mbtest/test/new # 1件ある
./mailbox.sh mailbox_drain test --effort low
cat /tmp/mbtest/test/replies.log # OK
ls /tmp/mbtest/test/done # 処理済みに移動している
jq '.' .company/scripts/cost-tracker.json | tail # mailbox-test のコストが記録されている
new/ が空のまま mailbox_drain を実行し、cost-tracker.json に新しい記録が増えないことも確認しておくと安心です。
実際に使ってみて
- 一番の失敗は、メッセージに権限の指示を入れ忘れたことです。当社では以前、AIが作った営業メールを確認なしで送信してしまい、敬語のおかしいメールが取引先に届いたことがあります。それ以来、外部に影響する作業は draft → 承認 → 実行 の流れを必ず通しています。セッション間のメッセージでも、受け手の
--allowedToolsを絞り、本文に「draftまで」と書くようにしています。 - セッションを増やす前に、CLAUDE.mdにどこまで共通ルールを書くかを決めておくのが大事です。CLAUDE.mdが1,000行を超えてAIの動きがおかしくなった経験があるので、今は200行以内にして、詳しいルールは
.claude/agents/に分けています。
おわりに
セッションが増えると、人間の役目は作業そのものよりもセッション同士の伝言になりがちです。対話セッション同士は ListAgents → SendMessage、無人ジョブからは claude -p とファイルのメールボックス、と使い分ければ、伝言のために人間が手を動かす必要はほとんどなくなります。
複数のエージェントにどう仕事を分け、どう連携させるかは『Claude Codeマルチエージェント開発』で詳しく解説しています。サブエージェントの設計や、部門ごとにエージェントを分けるときの考え方もまとめています。