4つのブログサイトを、ほぼ同じ振る舞いのエージェントで回しております。
違うのはシステムプロンプトの一部と、参照する MCP コネクタ、それに読み込ませるスキルだけです。それなのに私はしばらくの間、サイトごとに丸ごと別のエージェント定義を持っておりました。
共通部分を直すたびに4箇所を手で揃えます。片方だけ直し忘れて、ある夜のバッチだけ古いプロンプトで走ります。似た定義が少しずつずれていく感覚は、地味に神経を削るものでした。
Managed Agents の設計をきちんと読み直して、この構図をやっと畳めました。土台となるエージェント定義は1つに保ちます。そのうえで、サイトごとの色は「バージョンの固定」と「セッションローカルな上書き」という二つの層に振り分けます。定義を増やさずに、実行時に色を付ける発想です。
個人開発で4サイトを回すなかでたどり着いた設計を、実際に使っている検証コードとともにお伝えします。
なぜ「定義を増やす」ではなく「1つの土台を使い分ける」のか
選択肢は大きく3つありました。
| 方式 | 共通部分の同期 | 差分の見通し | 権限事故のリスク |
| サイトごとに別定義 | 手動で揃える必要あり | 良い(独立) | 低いが定義が増え続ける |
| 1定義+分岐をプロンプト内に埋める | 不要 | 悪い(肥大化) | 中(境界が曖昧) |
| 1定義+バージョンとセッションで使い分ける | 不要 | 良い(差分が明示) | 設計次第で低くできる |
3つ目を選んだ決め手は、差分がコードとして一箇所に集まることでした。
「このサイトのときは、この MCP と、このスキルだけ」という差分が、セッションを起動する直前に、読めるかたちで並びます。プロンプトの奥深くに条件分岐を埋めるより、はるかに見通しが利きます。
ただし、この方式には最初に必ずつまずく一点がありました。
直感に反した点:セッションに model や system は書けません
私が最初に書こうとしたコードは、こういうかたちでした。セッションを作るときに、そのセッション用のモデルとシステムプロンプトとツールを、まとめて渡すつもりだったのです。
そんな引数はありませんでした。
sessions.create() の agent フィールドが受け取るのは、エージェント ID の文字列か、{ type: "agent", id, version } というポインタだけです。model・system・tools ・mcp_servers・skills はすべて agents.create() 側、つまりエージェント本体の最上位フィールドとして定義されます。セッションは、その定義を指すだけの存在なのです。
このことに気づいたのは、セッション作成のパラメータ表を三度読み返したあとでした。「セッションごとに色を付ける」という発想そのものが間違っていたのではなく、色を付けられる層が私の想像より下にあった、というだけの話でした。
差し替えられるものと、差し替えられないものを整理しますと、次のようになります。
| 差し替えたいもの | 実際の手段 | タイミング |
model / system / skills | エージェントのバージョンを分ける(更新すると新しい版が積まれます) | セッション生成時にどの版を指すかで決まります |
tools / mcp_servers / vault_ids | sessions.update() によるセッションローカルな上書き | セッションが idle のあいだ |
| 環境(コンテナ構成) | environment_id をセッション生成時に指定します | セッション生成時のみ |
セッションローカルな上書きは、新しいエージェントのバージョンを作りません。エージェント本体にも波及しません。そのセッションのなかだけで効いて、セッションが終われば消えます。棚卸しの話が後ろで出てくるのは、この性質があるためです。
もうひとつの落とし穴:配列は「上書き」であって「統合」ではありません
土台エージェントには複数のツールを許可してあります。あるセッションで、そのうちの1つを別のツールに替えたいと考えました。ですから tools に、替えたい1つだけを渡しました。
結果、そのセッションでは残りのツールが消えました。
sessions.update() に渡した配列は、そのフィールドまるごとを置き換えます。要素単位でマージされるわけではありません。1要素だけ渡せば、そのセッションのツールはその1つだけになります。
| 上書きで渡した内容 | そのセッションでの結果 |
| フィールドを省略 | エージェント定義の値をそのまま継承します |
フィールドに空配列 [] | その要素は「無し」になります(継承ではありません) |
| フィールドに一部の要素だけ | 渡した要素だけになります(残りは消えます) |
言われてみれば当然の仕様です。けれど「上書き」という語感を「差分を足すこと」だと読み違えますと、ツールが減った状態のまま夜間バッチが走ることになります。私はログを見て初めて気づきました。
ここから得た原則はひとつでした。上書きで渡すのは、常にそのフィールドの最終形です。 部分的な足し引きをしたいのであれば、土台の値を読み込み、こちらで合成してから渡します。この合成を安全に行うのが、次のファクトリの役割です。
検証付きの起動ファクトリ
セッションを起動するコードに、差し替えの意図と安全網を一箇所へ集約します。TypeScript の実装です。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// 土台側で「許可してよい」上限。上書きがこれを超えたら止めます。
const TOOL_ALLOWLIST = new Set([
"agent_toolset_20260401",
"mcp_toolset",
]);
const MCP_ALLOWLIST = new Set([
"mcp-claudelab",
"mcp-gemilab",
"mcp-antigravitylab",
"mcp-rorklab",
]);
type SiteProfile = {
site: string;
agentId: string; // 土台は1つ。サイト差は version で指します
agentVersion?: number; // 省略時は最新版
environmentId: string;
toolTypes: string[]; // そのセッションでの最終形
mcpServers: { type: "url"; name: string; url: string }[];
};
function assertSubset(label: string, given: string[], allow: Set<string>) {
const stray = given.filter((v) => !allow.has(v));
if (stray.length > 0) {
// 想定外の権限拡大は、セッションを起動する前に落とします
throw new Error(`[guard] ${label} に未許可の項目: ${stray.join(", ")}`);
}
}
export async function startSession(p: SiteProfile) {
assertSubset("toolTypes", p.toolTypes, TOOL_ALLOWLIST);
assertSubset("mcpServers", p.mcpServers.map((m) => m.name), MCP_ALLOWLIST);
if (p.toolTypes.length === 0) {
// 空配列は「ツール無し」を意味します。事故を疑いたいので明示的に残します
console.warn(`[guard] ${p.site}: toolTypes が空です。意図的か確認してください`);
}
// 1. セッションはエージェントを「指す」だけ。ここに model や system は書けません
const session = await client.beta.sessions.create({
agent: p.agentVersion
? { type: "agent", id: p.agentId, version: p.agentVersion }
: p.agentId,
environment_id: p.environmentId,
title: `${p.site} nightly`,
metadata: { site: p.site },
});
// 2. ツールと MCP だけは、起動後にセッションローカルで上書きできます
// (session が idle のあいだのみ。配列は全置換です)
await client.beta.sessions.update(session.id, {
agent: {
tools: p.toolTypes.map((type) => ({ type })),
mcp_servers: p.mcpServers,
},
});
// 何をどう差し替えたかを一行で残します。あとから挙動を追えます
console.info(
`[session] site=${p.site} agent=${p.agentId}@${p.agentVersion ?? "(latest)"} ` +
`tools=${p.toolTypes.length} mcp=${p.mcpServers.map((m) => m.name).join("+")}`
);
return session;
}
要点は3つです。
第一に、assertSubset で「土台が許した上限」を起動前に検査します。上書きは全置換ですので、うっかり未登録の MCP 名を渡しますと、そのセッションだけ意図しない接続先になりかねません。許可集合を外れた時点で落とします。
第二に、空配列を握りつぶさずに警告します。「ツール無し」は正当な設定ではありますが、私の場合はほとんどがうっかりでした。黙って通さないことが、翌朝の後悔を減らしてくれます。
第三に、差し替えた内容を一行のログに残します。セッションローカルな上書きはセッション限りで消えますので、あとから「あのバッチはどの構成で走ったのか」を追う手掛かりが、ログ以外に残らないのです。
サイトごとの差分を、表の数行に閉じ込める
ファクトリを使いますと、サイトごとの差分は次のように短く、そして読めるかたちになります。
const SITE_PROFILES: Record<string, SiteProfile> = {
claudelab: {
site: "claudelab",
agentId: "agent_blog_base",
// version 省略 = 最新版に追随します
environmentId: "env_blog",
toolTypes: ["agent_toolset_20260401", "mcp_toolset"],
mcpServers: [
{ type: "url", name: "mcp-claudelab", url: "https://example.internal/cl/sse" },
],
},
rorklab: {
site: "rorklab",
agentId: "agent_blog_base",
agentVersion: 1772585501101368014, // 検証中のため版を固定しています
environmentId: "env_blog",
toolTypes: ["agent_toolset_20260401", "mcp_toolset"],
mcpServers: [
{ type: "url", name: "mcp-rorklab", url: "https://example.internal/rl/sse" },
],
},
};
async function runFor(site: string) {
const profile = SITE_PROFILES[site];
if (!profile) throw new Error(`未定義のサイトです: ${site}`);
return startSession(profile);
}
土台の agent_blog_base は1つだけです。共通の振る舞い、たとえば禁止語のルールや文体の基本方針は、この土台に一度だけ書きます。サイトごとに変わるのは、この表の数行に閉じ込められました。
新しいサイトのオンボーディングは、この表に1エントリ増やすだけになります。以前のように定義ファイルを丸ごと複製し、共通部分の同期に神経を使う必要は、もうありません。定義の重複が4本から1本に畳まれたことで、共通部分に手を入れるときの心理的な負荷がずいぶん軽くなったと感じています。
なお、システムプロンプトそのものをサイトごとに変えたい場合は、この層では足りません。system はエージェント本体のフィールドですので、サイト専用のエージェントを別に立てるか、共通の土台を「サイト名を metadata から読む」書き方にしておくかの、どちらかになります。私は後者を選びました。プロンプトを4本に増やした瞬間、最初の問題に逆戻りするためです。
足し引きしたいときは、土台を読んでから最終形を作る
「常に最終形を渡す」という原則は正しいのですが、実際に運用しますと「土台に1つ足したいだけ」の場面で毎回最終形を手で書き写すことになります。書き写しは、そのうち必ずずれます。
そこで私は、土台の現在値を読み出して足し引きを適用する小さなヘルパーを、ファクトリの手前に噛ませております。
type Delta = { add?: string[]; remove?: string[] };
// 土台の現在値に足し引きを適用し、「そのフィールドの最終形」と差分を返します
function applyDelta(current: string[], delta: Delta, label = "field") {
const remove = new Set(delta.remove ?? []);
// 土台側で名前が変わったのに remove 指定が古いまま、という状態を拾います
const ghosts = [...remove].filter((n) => !current.includes(n));
if (ghosts.length > 0) {
console.warn(`[delta] ${label}: 土台に無い項目の remove 指定: ${ghosts.join(", ")}`);
}
const kept = current.filter((n) => !remove.has(n));
const appended = (delta.add ?? []).filter((n) => !kept.includes(n));
const next = [...kept, ...appended];
return { next, diff: diffOf(current, next) };
}
function diffOf(before: string[], after: string[]): string[] {
const b = new Set(before);
const a = new Set(after);
return [
...before.filter((n) => !a.has(n)).map((n) => `-${n}`),
...after.filter((n) => !b.has(n)).map((n) => `+${n}`),
];
}
呼び出し側では、土台の現在値をプロセス起動時に一度だけ読んで使い回します。セッションごとに読み直しますと、その分だけ夜間バッチの立ち上がりが遅くなるためです。
// 土台の現在値は起動時に一度だけ取得します
const base = await client.beta.agents.retrieve("agent_blog_base");
const baseTools = (base.tools ?? []).map((t) => t.type);
const { next, diff } = applyDelta(baseTools, { add: ["mcp_toolset"] }, "tools");
console.info(`[delta] tools ${diff.join(" ") || "(変更なし)"} -> ${next.join(", ")}`);
// この next を SiteProfile.toolTypes に入れてから startSession へ渡します
土台が ["web_search", "text_editor"] の状態で、代表的な5パターンを手元の Node.js 22 で流した結果が次になります。
検証用に bash を足す -> [web_search, text_editor, bash] diff=[+bash]
検索を外す -> [text_editor] diff=[-web_search]
入れ替える -> [text_editor, bash] diff=[-web_search +bash]
既にある物を足す -> [web_search, text_editor] diff=[]
[delta] tools: 土台に無い項目の remove 指定: computer
土台に無い物を外す -> [web_search, text_editor] diff=[]
4行目のように、既にあるものを足しても重複は増えません。何度流しても同じ結果になりますので、設定ファイルを機械生成している場合でも安心して通せます。
効くのは最後の行です。土台のツール名を変えたのに、こちらの remove 指定が古い名前のまま残っておりますと、差分は空のまま何も起きません。黙って通りますと「外したつもりのツールが動き続ける」ことになります。警告が出るのは、それを防ぐためです。警告が標準エラーへ先に出ているため、結果の行より上に現れている点も、そのまま載せております。
そして diff は必ずログに残します。上書きは結果しか見えませんので、「何が変わるはずだったのか」が残らないと、レビューのしようがないのです。
運用してみて分かった勘所
本番運用で数週間回して、いくつか腑に落ちたことがあります。
セッションローカルな上書きは、そのセッションに閉じます。だからこそ棚卸しが要ります。 土台の定義は一覧できますが、各セッションが実際どの構成で走ったのかは、土台を見ても分かりません。私は上のログを日次で集計し、「どのサイトがどの版で何回走ったか」を残すようにしました。差し替えを許すのであれば、差し替えの記録は自分で持ちます。これが抜けますと、コスト按分も原因調査もできなくなります。
バージョンの継承は便利ですが、意図しない追従になり得ます。 agent に文字列 ID を渡しますと、セッション生成時点での最新版が使われます。土台を更新すれば全サイトが一斉に追随します。便利な反面、あるサイトだけ旧版で検証したいときには、{ type: "agent", id, version } のかたちで明示的に固定する必要があります。私は「継承してよい構成」と「固定すべき構成」を表のなかで分け、固定側には必ず agentVersion を書く運用をお勧めします。
差し替えの粒度は、変更頻度で決めますと落ち着きます。 よく変わるもの(参照コネクタ・許可するツール)はセッション側へ寄せます。めったに変わらないもの(禁止語・出力形式の根本方針)は土台へ残します。私はこの線引きを変更頻度で引くことにして、「どこを直すべきか」で迷わなくなりました。頻繁に触る差分が土台に混ざっておりますと、共通部分を触る心理的なハードルが上がってしまうのです。
日次の棚卸しは、ログ1本を集計するだけで足ります
「差し替えの記録は自分で持つ」と書きましたが、そのために用意しているものは大げさではありません。ファクトリが吐く [session] 行を、日付・サイト・版で畳むだけの小さなスクリプトです。
集計する前提として、ログ行の先頭に日付が要ります。実行基盤が付けてくれないのであれば、console.info の先頭に new Date().toISOString() を足しておいてください。
import { readFileSync } from "node:fs";
// [session] 行だけを拾います。先頭の日付は runner が付けても、行内に含めても構いません
const LINE =
/^(?<ts>\d{4}-\d{2}-\d{2})\S*\s+\[session\]\s+site=(?<site>\S+)\s+agent=(?<agent>\S+)\s+tools=(?<tools>\d+)\s+mcp=(?<mcp>\S+)/;
const rows = readFileSync(process.argv[2], "utf8")
.split("\n")
.map((l) => l.match(LINE)?.groups)
.filter(Boolean);
const agg = new Map();
for (const r of rows) {
const key = `${r.ts}\t${r.site}\t${r.agent}`;
const cur = agg.get(key) ?? { runs: 0, tools: new Set(), mcp: new Set() };
cur.runs++;
cur.tools.add(Number(r.tools)); // 同じ日に数が揺れたら列に残ります
r.mcp.split("+").forEach((m) => cur.mcp.add(m));
agg.set(key, cur);
}
console.log(["date", "site", "agent", "runs", "tools", "mcp"].join("\t"));
for (const [key, v] of [...agg].sort()) {
const tools = [...v.tools].sort((a, b) => a - b).join("/");
console.log([key, v.runs, tools, [...v.mcp].join(",")].join("\t"));
}
// 最新版に追随したまま走った回数は、土台を更新したときに波及する範囲そのものです
const latest = rows.filter((r) => r.agent.endsWith("@(latest)")).length;
console.log(`\n最新版に追随して走った回数: ${latest} / ${rows.length}`);
本番のログはそのまま出せませんので、同じ形式の2日分を起こして流した結果を載せます。
date site agent runs tools mcp
2026-09-01 claudelab agent_blog_base@(latest) 2 1/2 mcp-claudelab
2026-09-01 gemilab agent_blog_base@(latest) 1 2 mcp-gemilab
2026-09-01 rorklab agent_blog_base@17725855011… 1 3 mcp-rorklab
2026-09-02 claudelab agent_blog_base@(latest) 1 2 mcp-claudelab
2026-09-02 gemilab agent_blog_base@(latest) 1 2 mcp-gemilab,mcp-shared
2026-09-02 rorklab agent_blog_base@17725855011… 1 3 mcp-rorklab
最新版に追随して走った回数: 5 / 7
見ていただきたいのは1行目の tools 列です。1/2 は、同じ日に同じサイトが違うツール数で走ったことを意味します。私が最初にツールの取りこぼしに気づいたのは、まさにこの形の揺れを見たときでした。上書きの事故は例外を投げずに通ってしまいますので、こうした「揃っているはずの列が揃っていない」という形でしか表に出てこないのかもしれません。
mcp 列も同じ役割を持ちます。2日目の gemilab に mcp-shared が増えているのは、私が接続先を足したためでした。意図した変更であれば、日付をまたいで列が変わったことが記録として残ります。意図していなければ、その場で気づけます。
最後の1行は、土台を更新する前に見る数字です。7回のうち5回が最新版に追随しているのであれば、土台を差し替えた瞬間に5回分の挙動とコストが動きます。この数を把握してから土台に触るようになってから、更新が怖くなくなりました。
会話の途中で権限を絞りたいときは
ここまでの設計には、ひとつ前提がありました。ツールと MCP の上書きは、セッションが idle のあいだにしか通らないという点です。
エージェントが running の最中に sessions.update() を投げても受け付けられません。走っているものを一度中断し、idle に落としてから投げる必要があります。長い調査の途中で「もう検索は要らないので外したい」と考えたときには、この一手間が挟まります。
| 観点 | 起動直後の上書き | 会話の途中での上書き |
| 差し替えられる対象 | ツール・MCP サーバー・Vault | 同じ(対象は変わりません) |
| 前提となる状態 | 生成直後の idle | idle(running なら中断が必要です) |
| エージェント本体への波及 | ありません | ありません |
| 向く用途 | サイト別・案件別の色付け | 1回の作業のなかで権限を段階的に絞ること |
差し替えの中身は、起動時と同じ考え方で扱います。渡すのは常にそのフィールドの最終形であって、足し引きではありません。ですから検証も同じ関数を通します。
// フェーズごとの「ツールの最終形」を定義します
const PHASE_TOOLS: Record<"research" | "edit" | "verify", string[]> = {
research: ["agent_toolset_20260401", "mcp_toolset"],
edit: ["agent_toolset_20260401"],
verify: ["agent_toolset_20260401"],
};
// 次のフェーズの構成へ切り替えます。境界の判断は起動時と共有します
async function switchPhase(sessionId: string, phase: keyof typeof PHASE_TOOLS) {
const types = PHASE_TOOLS[phase];
assertSubset("toolTypes", types, TOOL_ALLOWLIST);
const current = await client.beta.sessions.retrieve(sessionId);
if (current.status !== "idle") {
// running のまま投げても通りません。中断してから呼び直します
throw new Error(`[phase] ${sessionId} は ${current.status} です。idle を待ってください`);
}
await client.beta.sessions.update(sessionId, {
agent: { tools: types.map((type) => ({ type })) },
});
// どの時点で権限が変わったかを、あとから追えるようにしておきます
console.info(`[phase] ${sessionId} -> ${phase} tools=${types.join(",")}`);
}
調査フェーズでは MCP の道具立てを許し、原稿を書き始めたら外します。検証フェーズでは必要なものだけに絞ります。中断のひと手間はありますが、フェーズごとに権限を絞る運用は、これで素直に書けるようになりました。
とはいえ私自身は、夜間の自動バッチについては起動直後の一度きりに寄せたままです。無人で走るものほど、構成が途中で変わらないほうが原因を追いやすいと考えているためです。会話の途中での切り替えは、手元で長い調査をするときに限って試している段階です。
うまくいかなかった3つのこと
順調に畳めた話ばかり書いてきましたが、途中でつまずいた点も残しておきます。
許可集合をコード側にだけ持って、土台と乖離させました。 TOOL_ALLOWLIST をコードにベタ書きしたまま、土台の定義を管理画面から編集してツールを1つ外したことがあります。コード側の集合は古いままでした。検証は通るのに、土台には存在しないツールを指定したセッションが作られる状態になっておりました。いまは起動時に土台を取得し、許可集合との差を突き合わせ、乖離があれば警告を出しております。境界を二重に持つのであれば、二重であることを検査する仕組みまで含めて設計するべきでした。
未定のフィールドを空配列で渡しておりました。 あるサイトの構成がまだ決まっていなかった時期、tools に [] を入れて放置しておりました。空配列は「継承」ではなく「無し」です。結果として、品質ゲートに当たる道具が読まれないまま数本が走りました。未定であれば空配列ではなくフィールドごと省略して継承させます。空配列は「無しにしたい」という意思表示のときだけ使います。この使い分けを、いまはコメントで明記しております。
固定したバージョンの見直しを忘れました。 検証のために1サイトだけ版を固定したところ、その後に土台を更新しても、固定した側だけが古いまま走り続けました。継承していれば自動で追随していた部分です。固定は便利ですが、固定した瞬間に「見直す責任」が発生します。いまは固定する側に、固定した理由と見直す時期をコメントで書き添えるようにしました。理由の書けない固定は、そもそも継承でよいはずだ、という判断基準にもなっております。
3つとも、根っこは同じでした。上書きは静かに効きますので、間違っていても例外が飛んできません。だからこそ、警告とログを自分で足す手間を惜しまないほうが、結局は早いというのが実感です。
どう使い分けるか
状況別の、私なりの結論です。差し替えを導入する前に、私は次の3点を先に決めております。
- どのフィールドを土台に残し、どれをセッション側へ出すか(変更頻度で線引きします)
- 許可集合(ツール・MCP)の上限をどこに置き、何を境界として落とすか
- セッションごとの構成をどのログに残し、日次でどう棚卸しするか
この3点を先に決めておきますと、権限事故を回避しやすくなります。
土台が2つも3つもあって、差分が大きいのであれば、無理にひとつへ寄せず別定義のままで構いません。差し替えの検証コストが、同期コストを上回ってしまいます。
一方、土台が実質1つで、差分が「参照コネクタと許可するツール」に収まるのであれば、この設計が効きます。私の4サイトは、まさにこの形でした。
まずは手元のエージェントを1つ選び、sessions.update() にツールの最終形を渡したうえで、その diff を1行ログに落とすところから試していただければと思います。私もそこから始めました。
同じように複数の似たエージェントを抱えている方の、設計を畳むきっかけになれば嬉しく思います。お読みいただきありがとうございました。