0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

📬 メールを書くように LLM へ指示を送る。LLMailer を作った話。

0
Last updated at Posted at 2026-09-13

はじめに

Claude Code は便利ですが、ターミナルの前に座り続けないといけません。
指示を出したら応答が返ってくるまで、その端末はそのタスクに占有されます。

「指示を投げたら席を立ちたい」「複数のモデルに同じ質問を投げて読み比べたい」
「あとから会話を掘り返したい」——この 3 つは、実はもうメールが 30 年前に解いている問題です。

そこで LLMailer を作りました。メールクライアントの操作感でローカルの Claude Code に指示を送る Next.js アプリです。

スクリーンショット 2026-09-13 19.06.21.png

メールの概念 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. メッセージは「相手」だけを持つ

素直に作ると fromto を持たせたくなりますが、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.jsonpermissions.allowWriteBash(...) を書いている人は多いと思いますが、
子プロセスはそれを継承するので、--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: falsespawn の既定)で起動し、プロンプトは引数ではなく標準入力から渡します。
引数長の上限もクォートのエスケープも気にしなくて済みます。

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 Codeclaude が PATH にあること)だけです。API キーは要りません。

  1. 左サイドバーの「✉ 新規作成」
  2. 宛先(エージェント)を選び、件名と本文を書く
  3. 「送信」で作成ウィンドウが閉じ、スレッドが開いて「対応中…」
  4. 応答が届くとスレッドに追加される
  5. 「↩ 返信」で会話を続ける(セッションが --resume される)

本文を書いている途中で ⌘(Ctrl)+ Enter でそのまま送信できます。
受信メッセージには実行コスト・所要時間・ターン数・トークン数が出るので、
「この質問に何円かかったか」が分かります。

まとめ

LLM のクライアントというと真っ先にチャットが浮かびますが、
ツールを使って数分働くエージェントの相手は、むしろ非同期のメールに近いというのが作ってみての実感です。

設計面で持ち帰れるところをまとめると:

  • 導出できるものは導出する。 スレッドを実体にせず Message[] から作ると不整合が消える
  • フラグより日時。 アーカイブを日時で持つと「再開したか」が比較だけで分かる
  • 待たせる状態はサーバーに置く。 「対応中」をブラウザのメモリに持つとリロードで消える
  • 担当プロセス ID を持たせる。 誰も面倒を見ていない処理中を検出できる
  • --allowedTools は「追加で許可」。 読み取り専用は deny 側と settingSources で作る

最後のひとつは Claude Code をプログラムから叩く人全員に効く話なので、
自前のラッパーを書いている方はぜひ一度、読み取り専用のつもりのエージェントに
「適当なファイルを作って」と頼んでみてください。作れてしまったら、この記事の通りです。

リポジトリは公開しているので、よければ覗いてみてください 🙌

参考リンク

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?