はじめに
Claude Code は便利ですが、ターミナルの前に座り続けないといけません。
指示を出したら応答が返ってくるまで、その端末はそのタスクに占有されます。
「指示を投げたら席を立ちたい」「複数のモデルに同じ質問を投げて読み比べたい」
「あとから会話を掘り返したい」——この 3 つは、実はもうメールが 30 年前に解いている問題です。
そこで LLMailer を作りました。メールクライアントの操作感でローカルの Claude Code に指示を送る Next.js アプリです。
| メールの概念 | LLMailer での意味 |
|---|---|
| 宛先(To) | AI エージェント(モデル・権限・作業ディレクトリの組) |
| 件名(Subject) | スレッドのお題 |
| 本文(Body) | 指示 |
| 返信 | Claude Code の応答 |
| スレッド | Claude Code のセッション(--resume で継続) |
| アドレス帳 | エージェント定義一覧 |
| 配信失敗通知 | 実行エラー・タイムアウト |
中身はいたってシンプルで、claude コマンドを非対話モードで叩いているだけです。
import { spawn } from 'node:child_process';
// -p(非対話)+ JSON 出力で 1 往復させる。プロンプトは標準入力から渡す
const child = spawn('claude', [
'-p',
'--output-format', 'json',
'--model', 'opus',
'--session-id', sessionId, // 次の返信で --resume できるようにする
'--permission-mode', 'manual',
'--allowedTools', 'Read,Glob,Grep', // 可変長オプションはカンマ区切りの 1 引数で
], { cwd: workingDirectory, stdio: ['pipe', 'pipe', 'pipe'] });
child.stdin.end('件名: 設計レビュー\n\nsrc/lib 配下の責務分担を見てほしい', 'utf-8');
Claude API のキーは不要です。すでにログイン済みの Claude Code をそのまま使います。
📝 3行まとめ
- メールの UI をそのまま LLM のクライアントにした。宛先=エージェント、件名=お題、返信=Claude Code の応答。
-
送信は配信完了を待たない。Next.js の
after()でバックグラウンド配信し、「対応中」はサーバー側の状態として保存する。だからリロードしても消えない。 -
権限は宛先ごとに分ける。
--allowedToolsだけでは読み取り専用にならない(グローバル設定のpermissions.allowを打ち消せない)という罠がある。
🤔 なぜメールなのか
チャット UI は「今この瞬間、画面の前にいること」を前提にしています。
一方で Claude Code の仕事は、ツールを使ってファイルを読み、コマンドを流し、数分かかることも珍しくありません。
これはチャットというより、非同期のやり取りです。
メールクライアントが持っている概念は、そのまま LLM のクライアントとして機能します。
- 受信箱 — 返ってきた応答が溜まる場所。未読件数が出る
-
スレッド — 1 つのお題の会話。
--resumeでセッションが継続する - 複数宛先 — 同じ指示を Opus / Sonnet / Haiku に並行配信して読み比べる
- アーカイブ — 対応が済んだものを一覧から外す(削除ではない)
- 下書き — 書きかけの指示を保存しておく
特に「複数宛先」はメール由来ならではで、宛先を複数選ぶと同じ本文が各エージェントへ同時に飛び、返信が並んで届きます。
🏗 技術スタック
| 項目 | 内容 |
|---|---|
| フレームワーク | Next.js 16(App Router / Route Handlers) |
| UI | React 19 + CSS Modules(UI ライブラリは入れない) |
| 言語 | TypeScript(strict) |
| 永続化 |
data/ 配下の JSON ファイル(DB なし) |
| LLM | ローカルの claude コマンド |
依存は next / react / react-markdown + remark プラグインだけ。
状態管理ライブラリも UI ライブラリも入れていません。個人ツールなので DB も立てず、JSON ファイルに落としています。
💡 設計で効いた 4 つの判断
1. メッセージが唯一の真実の source
スレッドという実体は持ちません。 Message[] からその都度導出します。
/** Message[] から導出するスレッド */
export interface Thread {
readonly id: string;
readonly subject: string;
readonly participants: readonly string[];
readonly lastMessageAt: string;
readonly unreadCount: number;
readonly hasPending: boolean;
readonly archived: boolean;
}
件名も参加エージェントも未読件数も、メッセージを見れば分かります。
スレッド側に持たせると「メッセージは増えたのにスレッドの更新を忘れた」という不整合が生まれるので、
導出できない情報だけを別に持ちます。それがアーカイブです。
しかもアーカイブは真偽値ではなく日時で持っています。
// data/threads/states.json
{ "thread-abc": { "archivedAt": "2026-09-13T09:00:00.000Z" } }
こうすると「アーカイブしたあとに返信が届いた」=「その日時より後のメッセージがある」=
やり取りが再開した、と自然に判定できます。真偽値だと「アーカイブを解除する」処理を
あちこちに書く羽目になりますが、日時なら比較するだけで済みます。
2. メッセージは「相手」だけを持つ
素直に作ると from と to を持たせたくなりますが、LLMailer のやり取りは常に「自分 ↔ エージェント」です。
そこで相手のエージェントだけを持ち、向きは配信状態から導出します。
/**
* 自分が出したメッセージか。
* やり取りは常に「自分 ↔ エージェント」なので、配信状態から向きが決まる。
*/
export const isOutgoingMessage = (message: Message): boolean =>
message.status === 'draft' || message.status === 'sent';
利用者を表すアドレスを作らずに済みます。ログインもアカウントもない個人ツールなので、
「自分」に ID を振らないのは地味に効きます。
3. 送信は配信を待たない(after() と「対応中」)
これが一番の肝です。Claude Code の応答は数分かかることがあるので、
POST /api/messages は配信の完了を待ちません。
// 送信と「対応中」を先に保存してから配信する。
// こうすると、配信の途中でブラウザを閉じても状態が残る
const pending = knownRecipients.map((agent) => createPendingMessage(agent, sent));
await saveMessages([sent, ...pending]);
// 配信の完了は待たずに応答を返す。返信は届き次第プレースホルダへ上書きする
after(async () => {
await Promise.all(
knownRecipients.map(async (agent, index) => {
const reply = await deliverTo(agent, sent, pending[index], history, agentNames);
await saveMessage(reply);
})
);
});
return NextResponse.json({ sent, pending });
ポイントは 「対応中」をプレースホルダのメッセージとして永続化していることです。
ブラウザの useState に「送信中フラグ」を持つと、リロードで消え、別タブでは見えません。
サーバー側の状態にしておけば、リロードしても別タブで開いても「対応中…」が出ます。
返信が届いたらプレースホルダと同じ ID で上書きするので、1 通が「対応中」→「返信」へ差し替わります。
const base = {
// プレースホルダと同じ ID で保存し、「対応中」の 1 通を結果へ差し替える
id: pending.id,
// …
} as const;
取り残された「対応中」をどうするか
サーバーを止めると、after() の中で走っていた配信は消えます。
その返信はもう永遠に届きません。なのに「対応中」のプレースホルダだけが JSON に残ります。
そこでプレースホルダに配信担当のプロセス ID を持たせています。
readonly deliveryProcessId?: string;
読み出し時に「自分(現在のプロセス)以外の ID が付いた対応中」を見つけたら、配信失敗へ倒します。
利用者から見ると、サーバーを再起動したあとに開いたスレッドは
「配信失敗」になっていて、そのまま再送できます。永久に回り続けるスピナーを見ずに済みます。
4. 会話履歴は Claude Code 側が持つ
スレッドの会話履歴を毎回まるごとプロンプトに詰めると、トークンがどんどん膨らみます。
LLMailer は履歴を持たせません。Claude Code のセッションが持っているからです。
if (resumeSessionId) {
args.push('--resume', resumeSessionId);
} else {
// スレッド初回は ID を指定して開始し、以降の返信で resume できるようにする
args.push('--session-id', newSessionId);
args.push('--append-system-prompt', rolePrompt);
}
LLMailer が渡すのは**「そのエージェントが前回応答して以降に増えたぶん」だけ**です。
const lastReply = [...history].reverse()
.find((m) => m.status === 'received' && isFromThisAgent(m));
const newMessages = history.filter(
(m) => (m.status === 'sent' || m.status === 'received') &&
!isFromThisAgent(m) &&
(!lastReply || m.createdAt > lastReply.createdAt)
);
複数宛先のときは、他のエージェントの回答も名前を付けて畳み込んで渡します。
会議の議事録を共有するイメージです。
return `【${name} の回答】\n${message.body}`;
これで「Opus はこう言っているけど、あなたはどう思う?」という会話が成立します。
🔐 権限の話(ここが一番ハマった)
メール 1 通でローカルのファイルが書き換わりうるので、権限は宛先ごとに分けています。
アドレス帳には フル権限 / 読み取り専用 のバッジが並びます。
--allowedTools だけでは読み取り専用にならない
最初にハマったのがこれです。
--allowedTools は「追加で許可する」指定であって、「これだけに絞る」指定ではありません。
~/.claude/settings.json に permissions.allow で Write や Bash(...) を書いている人は多いと思いますが、
子プロセスはそれを継承するので、--allowedTools Read,Glob,Grep と渡しても書けてしまいます。
確実に読み取りだけに絞るには、次の 2 つを併用します。
{
"permissionMode": "manual",
"allowedTools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
"disallowedTools": ["Write", "Edit", "MultiEdit", "NotebookEdit",
"Bash", "BashOutput", "KillShell", "KillBash", "Task"],
"settingSources": ["project", "local"] // 'user' を外す
}
-
deny(
disallowedTools)は allow より優先されるので、明示的に無効化する -
settingSourcesからuserを外すと~/.claude/settings.jsonを読まなくなり、グローバルの allow を継承しない -
Taskも落とす。サブエージェント経由で書き込まれるのを防ぐため
この組み合わせを毎回手で書くのは事故のもとなので、画面からは 2 つのプリセットしか選べないようにしました。
/** プリセットから、Claude Code に渡す権限設定を組み立てる */
export const toAgentPermission = (access: AgentAccess): AgentPermission =>
access === 'full'
? { permissionMode: 'bypassPermissions' }
: {
permissionMode: 'manual',
allowedTools: READ_ONLY_TOOLS,
disallowedTools: DENY_WRITE_TOOLS,
settingSources: ['project', 'local'],
};
「できたふり」を防ぐ
読み取り専用エージェントに「このファイルを直して」と頼むと、
ツールが無効化されているのに「修正しました」と返してくることがあります。
これは嘘の報告なので、--append-system-prompt で何ができて何ができないかを具体的に伝えています。
const readOnlyNotice = [
'あなたは読み取り専用です。',
`ファイルの読み取りと検索(${workingDirectory} 配下)、および Web 検索・Web ページの取得は使えます。`,
'ただしファイルの作成・変更とコマンド実行はできません(ツールが無効化されています)。',
'できない依頼には、できたふりをせず「できません」と正直に返信し、代わりに内容や手順を文章で示してください。',
].join('');
あわせて、Claude Code の実行結果に含まれる permission_denials を拾って
「権限で実行されなかったツール」を返信に表示しています。何が弾かれたのかが見えます。
⚙️ 子プロセス起動でハマった細かい罠
可変長オプションは後続の引数を飲み込む
--allowedTools Read Glob Grep --disallowedTools Write のようにスペース区切りで渡すと、
後ろのオプションまで値として飲み込まれます。カンマ区切りの 1 引数で渡すのが安全です。
if (agent.allowedTools && agent.allowedTools.length > 0) {
args.push('--allowedTools', agent.allowedTools.join(','));
}
CLAUDE* 環境変数を落とす
LLMailer 自身を Claude Code から起動して開発していると、CLAUDE_CODE_* 系の環境変数が
子プロセスへ継承され、挙動がおかしくなります。渡す前に落とします。
/** 親プロセスが Claude Code 内で動いている場合に継承される変数を落とす */
const CLAUDE_ENV_ALLOW_LIST: readonly string[] = ['CLAUDE_CONFIG_DIR'];
const buildEnv = (): NodeJS.ProcessEnv => {
const env: NodeJS.ProcessEnv = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith('CLAUDE') && !CLAUDE_ENV_ALLOW_LIST.includes(key)) {
delete env[key];
}
}
return env;
};
プロンプトは標準入力から
shell: false(spawn の既定)で起動し、プロンプトは引数ではなく標準入力から渡します。
引数長の上限もクォートのエスケープも気にしなくて済みます。
child.stdin.end(prompt, 'utf-8');
出力 JSON は「末尾のオブジェクト」を拾う
起動時の警告が標準出力に混ざることがあるので、最初の { と最後の } を取り出してパースしています。
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
const parsed: unknown = JSON.parse(trimmed.slice(start, end + 1));
タイムアウトは SIGTERM → SIGKILL
既定 10 分。SIGTERM を送ったあと 5 秒の猶予を置いて SIGKILL します。
💾 永続化は JSON ファイル
DB は立てていません。data/ 配下の JSON です。
| パス | 内容 |
|---|---|
data/agents/default.json |
同梱のエージェント(コミット対象) |
data/agents/custom.json |
画面から追加したぶん(.gitignore 済み) |
data/threads/<threadId>.json |
スレッドごとの送受信メッセージ |
data/threads/drafts.json |
下書き |
data/threads/states.json |
スレッドのアーカイブ日時 |
工夫は 2 点です。
1 ファイル = 1 スレッド。 1 つの JSON にまとめると、返信が 1 通増えるたびに
それまでのやり取りを全部書き直すことになります。ファイル名がそのままスレッド ID です
(なので ID は [A-Za-z0-9_-]+ に限定し、drafts / states / index は予約語として弾いています)。
既定と利用者ぶんを分ける。 個人のエージェント定義には作業ディレクトリの絶対パスが入るので、
git の差分に出したくありません。default.json はコミット対象、custom.json は .gitignore。
どちらに入っているかで isDefault を付けて返し、画面から既定の変更・削除をできないようにしています。
🧵 スレッド ID はクライアントで決める
地味ですが効いています。POST /api/messages の応答を待たずにそのスレッドを開きたいので、
スレッド ID はクライアントで採番します(サーバーは受け取った ID をそのまま使い、なければ採番)。
送信ボタンを押した瞬間に作成ウィンドウが閉じ、そのスレッドが開いて「対応中…」が出る、
という体験はこれで成り立っています。
🔄 差し替えられる形を保つ
配信は Transport インターフェース越しに呼び、UI や API から lib/claudeCode を直接触りません。
/**
* 「宛先へメッセージを配信し、返信を得る」抽象。
* 既定実装はローカルの Claude Code だが、同じ形で実メール(SMTP/IMAP)へ差し替えられる。
*/
export interface Transport {
deliver(input: DeliverInput): Promise<DeliverResult>;
}
「メールの比喩で作る」と決めたので、本当にメールを送る実装に差し替えられるのは自然な着地点です。
ストリーミング受信も Transport.deliverStream() を足して SSE 化する形で入れられます。
🚀 使ってみる
git clone https://github.com/NaokiIshimura/llmailer
cd llmailer
npm install
npm run dev
http://localhost:25110 を開きます。必要なのは Node.js 20 以上 と
ログイン済みの Claude Code(claude が PATH にあること)だけです。API キーは要りません。
- 左サイドバーの「✉ 新規作成」
- 宛先(エージェント)を選び、件名と本文を書く
- 「送信」で作成ウィンドウが閉じ、スレッドが開いて「対応中…」
- 応答が届くとスレッドに追加される
- 「↩ 返信」で会話を続ける(セッションが
--resumeされる)
本文を書いている途中で ⌘(Ctrl)+ Enter でそのまま送信できます。
受信メッセージには実行コスト・所要時間・ターン数・トークン数が出るので、
「この質問に何円かかったか」が分かります。
まとめ
LLM のクライアントというと真っ先にチャットが浮かびますが、
ツールを使って数分働くエージェントの相手は、むしろ非同期のメールに近いというのが作ってみての実感です。
設計面で持ち帰れるところをまとめると:
-
導出できるものは導出する。 スレッドを実体にせず
Message[]から作ると不整合が消える - フラグより日時。 アーカイブを日時で持つと「再開したか」が比較だけで分かる
- 待たせる状態はサーバーに置く。 「対応中」をブラウザのメモリに持つとリロードで消える
- 担当プロセス ID を持たせる。 誰も面倒を見ていない処理中を検出できる
-
--allowedToolsは「追加で許可」。 読み取り専用は deny 側とsettingSourcesで作る
最後のひとつは Claude Code をプログラムから叩く人全員に効く話なので、
自前のラッパーを書いている方はぜひ一度、読み取り専用のつもりのエージェントに
「適当なファイルを作って」と頼んでみてください。作れてしまったら、この記事の通りです。
リポジトリは公開しているので、よければ覗いてみてください 🙌
