4つのブログサイトを、ほぼ同じ振る舞いのエージェントで回しています。
違うのはシステムプロンプトの一部と、参照する MCP コネクタ、それに読み込ませるスキルだけ。それなのに、私はしばらくの間、サイトごとに丸ごと別のエージェント定義を持っていました。
共通部分を直すたびに4箇所を手で揃える。片方だけ直し忘れて、ある夜のバッチだけ古いプロンプトで走る。似た定義が少しずつずれていく感覚は、地味に神経を削ります。
Managed Agents の agent_with_overrides に出会って、この構図をやっと畳めました。土台となるエージェント定義は1つ。セッションを作るその瞬間だけ、モデル・システムプロンプト・ツール・MCP サーバー・スキルを差し替える。定義は増やさず、実行時に色を付ける発想です。
個人開発で4サイトを回す中でたどり着いた設計です。以下では、その差し替えを「事故が起きない形」に落とし込むための考え方を、実際に使っている検証コードとともに共有します。
なぜ「定義を増やす」ではなく「セッションで差し替える」のか
選択肢は大きく3つありました。
方式 共通部分の同期 差分の見通し 権限事故のリスク
サイトごとに別定義 手動・ずれやすい 良い(独立) 低いが定義爆発
1定義+分岐をプロンプト内に埋める 不要 悪い(肥大化) 中(境界が曖昧)
1定義+セッションでオーバーライド 不要 良い(差分が明示) 設計次第で低くできる
3つ目を選んだ決め手は、差分がコードとして一箇所に集まることでした。
「このサイトのときは、この MCP と、このスキルだけ」という差分が、セッション生成の直前に、読める形で並ぶ。プロンプトの奥深くに条件分岐を埋めるより、はるかに見通しが利きます。
ただし、この方式には最初に必ずつまずく一点があります。
直感に反した点:オーバーライドは「上書き」であって「統合」ではない
最初、私はオーバーライドを「土台に差分を足すもの」だと思い込んでいました。
土台エージェントには4つのツールを許可してある。あるセッションで、そのうちの1つを別ツールに替えたい。だから tools に替えたい1つだけを渡しました。
結果、そのセッションでは残り3つのツールが消えました。
agent_with_overrides で指定したフィールドは、そのフィールドまるごとを置き換えます。配列であっても、要素単位でマージされるわけではありません。tools に1要素だけ渡せば、そのセッションのツールはその1つだけになります。
オーバーライドで渡した内容 そのセッションでの結果
フィールドを省略 土台の値をそのまま継承
フィールドに空配列 [] その要素は「無し」になる(継承ではない)
フィールドに一部の要素だけ 渡した要素だけになる(残りは消える)
言われてみれば当然の仕様です。けれど「差分を足す」という語感に引きずられると、静かにツールが減った状態で夜間バッチが走ることになります。私はログを見て初めて気づきました。
ここから得た原則はひとつ。オーバーライドは常に「そのフィールドの最終形」を渡す。 部分的な足し引きをしたいなら、土台の値を読み込んで、こちらで合成してから渡す。この合成を安全にやるのが、次のファクトリの役割です。
検証付きセッションファクトリ
セッションを作るコードに、差し替えの意図と安全網を一箇所へ集約します。TypeScript(Agent SDK 想定)の実装です。
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ();
// 土台エージェントで「許可してよい」上限。オーバーライドがこれを超えたら止める。
const TOOL_ALLOWLIST = new Set ([
"web_search" ,
"bash" ,
"text_editor" ,
]);
const MCP_ALLOWLIST = new Set ([
"mcp-claudelab" ,
"mcp-gemilab" ,
"mcp-antigravitylab" ,
"mcp-rorklab" ,
]);
type SiteOverride = {
site : string ;
systemPrompt : string ;
tools : string []; // そのセッションでの最終形
mcpServers : string []; // そのセッションでの最終形
skills : string [];
model ?: string ;
};
function assertSubset ( name : string , given : string [], allow : Set < string >) {
const stray = given. filter (( v ) => ! allow. has (v));
if (stray. length > 0 ) {
// 想定外の権限拡大を、実行前に必ず落とす
throw new Error (
`[override guard] ${ name } に未許可の項目: ${ stray . join ( ", " ) }`
);
}
}
export function buildSessionAgent ( base : string , o : SiteOverride ) {
// オーバーライドは「上書き」なので、渡す前にこちら側で境界を検証する
assertSubset ( "tools" , o.tools, TOOL_ALLOWLIST );
assertSubset ( "mcpServers" , o.mcpServers, MCP_ALLOWLIST );
if (o.tools. length === 0 ) {
// 空配列は「ツール無し」を意味する。事故を疑いたいので明示ログ。
console. warn ( `[override] ${ o . site }: tools が空。意図的か確認すること` );
}
const overridden = {
type: "agent_with_overrides" as const ,
agent_id: base,
overrides: {
model: o.model, // 省略時は土台のモデルを継承
system: o.systemPrompt,
tools: o.tools. map (( name ) => ({ name })),
mcp_servers: o.mcpServers,
skills: o.skills,
},
};
// 何をどう差し替えたかを一行で残す。あとで挙動を追える。
console. info (
`[session] site=${ o . site } model=${ o . model ?? "(inherit)"} ` +
`tools=${ o . tools . length } mcp=${ o . mcpServers . join ( "+" ) }`
);
return overridden;
}
要点は3つです。
第一に、assertSubset で「土台が許した上限」を実行前に検査します。オーバーライドは上書きなので、うっかり未登録の MCP 名を渡すと、そのセッションだけ意図しない接続先になりかねません。許可集合を外れたら、セッションを作る前に落とします。
第二に、空配列を握りつぶさず警告します。「ツール無し」は正当な設定ですが、私の経験では9割方うっかりです。黙って通さないことが、翌朝の後悔を減らします。
第三に、差し替えた内容を一行のログに残します。オーバーライドはセッション限りで消えるため、あとから「あのバッチはどの構成で走ったか」を追う手掛かりが、ログ以外に残りません。
セッション側で組み立てて呼ぶ
ファクトリを使うと、サイトごとの差分は次のように短く、そして読める形になります。
const SITE_OVERRIDES : Record < string , SiteOverride > = {
claudelab: {
site: "claudelab" ,
systemPrompt: "あなたは Claude 技術ブログの編集者です。敬体で書きます。" ,
tools: [ "web_search" , "text_editor" ],
mcpServers: [ "mcp-claudelab" ],
skills: [ "article-gate" , "templating-gate" ],
},
rorklab: {
site: "rorklab" ,
systemPrompt: "あなたは Rork アプリ開発ブログの編集者です。敬体で書きます。" ,
tools: [ "web_search" , "text_editor" , "bash" ], // Rork はビルド確認で bash を使う
mcpServers: [ "mcp-rorklab" ],
skills: [ "article-gate" ],
model: "claude-opus-4-8" ,
},
};
async function runFor ( site : string ) {
const override = SITE_OVERRIDES [site];
if ( ! override) throw new Error ( `未定義のサイト: ${ site }` );
const session = await client.beta.messages.sessions. create ({
agent: buildSessionAgent ( "agent_blog_base" , override),
});
return session;
}
土台の agent_blog_base は1つだけ。共通の振る舞い、たとえば禁止語のルールや文体の基本方針は、この土台に一度だけ書きます。サイトごとに変わるのは、この表の数行に閉じ込められました。
新しいサイトのオンボーディングは、この表に1エントリ増やすだけ。以前のように定義ファイルを丸ごと複製し、共通部分の同期に神経を使う必要は、もうありません。
定義の重複は4本から1本へ。共通部分を直す箇所も4回から1回に減り、同期にかけていた手間はおよそ75%削減できました。
足し引きしたいときは、土台を読んでから最終形を作る
「常に最終形を渡す」という原則は正しいのですが、実際に運用すると「土台に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 }` ),
];
}
呼び出し側では、土台の現在値をプロセス起動時に一度だけ読んで使い回します。セッションごとに読み直すと、その分だけ夜間バッチの立ち上がりが遅くなるためです。
// 土台の現在値は起動時に一度だけ取得する(メソッド名は SDK の版で変わり得ます)
const base = await client.beta.agents. retrieve ( "agent_blog_base" );
const baseTools = (base.tools ?? []). map (( t ) => t.name);
const { next , diff } = applyDelta (baseTools, { add: [ "bash" ] }, "tools" );
console. info ( `[delta] tools ${ diff . join ( " " ) || "(変更なし)"} -> ${ next . join ( ", " ) }` );
// この next を SiteOverride.tools に入れてから buildSessionAgent へ渡す
土台が ["web_search", "text_editor"] の状態で、代表的な5パターンを流した結果です。
検証用に 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=[]
土台に無い物を外す -> [web_search, text_editor] diff=[]
[delta] tools: 土台に無い項目の remove 指定: computer
4行目のように、既にあるものを足しても重複は増えません。何度流しても同じ結果になるので、設定ファイルを機械生成している場合でも安心して通せます。
効くのは5行目です。土台のツール名を変えたのに、こちらの remove 指定が古い名前のまま残っていると、差分は空のまま何も起きません。黙って通ると「外したつもりのツールが動き続ける」ことになります。警告が出るのはそれを防ぐためです。
そして diff を必ずログに残します。オーバーライドは結果しか見えないので、「何が変わるはずだったか」が残らないと、レビューのしようがありません。
運用してみて分かった、公式の説明に無い勘所
本番運用で数週間回して、いくつか腑に落ちたことがあります。
オーバーライドはセッションに閉じる。だからこそ棚卸しが要る。 土台の定義は Console から一覧できますが、各セッションが実際どの構成で走ったかは、土台を見ても分かりません。私は上のログを日次で集計し、「どのサイトがどのモデルで何回走ったか」を残すようにしました。差し替えを許すなら、差し替えの記録は自分で持つ。これが抜けると、コスト按分も原因調査もできなくなります。
モデルの継承は便利だが、意図しない追従になり得る。 model を省略すると土台のモデルを継承します。土台のモデルを上げれば全サイトが一斉に上がる。便利な反面、あるサイトだけ旧モデルで検証したいときは、省略せず明示的に固定する必要があります。私は「継承していい構成」と「固定すべき構成」を表の中で分け、固定側には必ず model を書く運用を推奨します。
差し替え粒度は、変更頻度で決めると落ち着く。 よく変わるもの(プロンプトの一部・参照コネクタ)はオーバーライドへ。めったに変わらないもの(禁止語・出力形式の根本方針)は土台へ。私はこの線引きを変更頻度で引くことにして、「どこを直すべきか」で迷わなくなりました。頻繁に触る差分が土台に混ざっていると、共通部分を触る心理的なハードルが上がります。
日次の棚卸しは、ログ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 + model=(?< model > \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 . model }` ;
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" , "model" , "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 inherited = rows. filter (( r ) => r.model === "(inherit)" ). length ;
console. log ( ` \n 継承(inherit)で走った回数: ${ inherited } / ${ rows . length }` );
本番のログはそのまま出せないため、同じ形式の2日分を起こして流した結果を載せます。
date site model runs tools mcp
2026-07-28 claudelab (inherit) 2 1/2 mcp-claudelab
2026-07-28 gemilab (inherit) 1 2 mcp-gemilab
2026-07-28 rorklab claude-opus-4-8 1 3 mcp-rorklab
2026-07-29 claudelab (inherit) 1 2 mcp-claudelab
2026-07-29 gemilab (inherit) 1 2 mcp-gemilab,mcp-shared
2026-07-29 rorklab claude-opus-4-8 1 3 mcp-rorklab
継承(inherit)で走った回数: 5 / 7
見てほしいのは1行目の tools 列です。1/2 は、同じ日に同じサイトが違うツール数で走ったことを意味します。私が最初にツールの取りこぼしに気づいたのは、まさにこの形の揺れを見たときでした。オーバーライドの事故は例外を投げずに静かに通るので、こういう「揃っているはずの列が揃っていない」という形でしか表に出てきません。
mcp 列も同じ役割を持ちます。2日目の gemilab に mcp-shared が増えているのは、私が接続先を足したためでした。意図した変更なら、日付をまたいで列が変わったことが記録として残る。意図していないなら、その場で気づけます。
最後の1行は、土台のモデルを上げる前に見る数字です。7回中5回が継承で走っているなら、土台を差し替えた瞬間に5回分の挙動とコストが動きます。この数を把握してから土台を触るようになってから、モデル更新が怖くなくなりました。
セッションで差し替えるか、会話の途中で差し替えるか
ここまでの設計には、ひとつだけ動かせない前提がありました。差し替えられるのはセッションを作るその一瞬だけ、という点です。
長い調査を回している最中に「もう検索は要らないから外したい」と思っても、方法はセッションを作り直すことしかありませんでした。作り直せばプロンプトキャッシュも一緒に捨てることになります。結果として私は、フェーズごとに権限を絞る発想そのものを諦めて、最初に広めの権限を渡したまま最後まで走らせていました。
2026年7月28日にベータとして入った「会話の途中でのツール追加・削除」は、この前提を崩します。ターンとターンの間でツールの集合を入れ替えても、プロンプトキャッシュを保ったまま会話を続けられる、というものです。対象は Claude Fable 5・Mythos 5・Opus 4.8・Opus 5 です。ベータの仕様は変わり得ますので、組み込む前に一次情報での確認をおすすめします。
観点 セッション単位オーバーライド 会話途中のツール変更
差し替えられる対象 モデル・プロンプト・ツール・MCP・スキル ツールの集合
適用のタイミング セッション生成時のみ ターンの境目
プロンプトキャッシュ 新しいセッションなので積み直し 維持される
向く用途 サイト別・案件別の色付け 1回の作業の中で権限を段階的に絞る
キャッシュが保たれるとはいえ、並べ方には気を配る価値があります。キャッシュはプレフィックス単位で効くため、途中で触る可能性のあるものを前方に置いていると、その後ろが道連れになりやすい。私は「会話中に入れ替える可能性があるツールを、固定のツールより後ろに寄せる」ことを前提にした並びで組んでいます。
差し替えの中身は、セッション単位のときと同じ考え方で扱います。渡すのは常にそのフィールドの最終形であって、足し引きではありません。だから検証も同じ関数を通します。
// フェーズごとの「ツールの最終形」。会話中に入れ替える想定のものを後ろへ寄せる。
const PHASE_TOOLS : Record < "research" | "edit" | "verify" , string []> = {
research: [ "text_editor" , "web_search" ],
edit: [ "text_editor" ],
verify: [ "text_editor" , "bash" ],
};
// 次のターンに渡すツール定義を組み立てる。境界の判断はセッション生成時と共有する。
function toolsForPhase ( sessionId : string , phase : keyof typeof PHASE_TOOLS ) {
const tools = PHASE_TOOLS [phase];
assertSubset ( "tools" , tools, TOOL_ALLOWLIST );
// どのターンで権限が変わったかを、あとから追えるようにしておく
console. info ( `[phase] ${ sessionId } -> ${ phase } tools=${ tools . join ( "," ) }` );
return tools. map (( name ) => ({ name }));
}
調査フェーズでは web_search を許し、原稿を書き始めたら外す。検証フェーズでだけ bash を渡す。以前ならフェーズを分けるほどキャッシュの積み直しが増えて割に合わなかった操作が、そのまま素直に書けるようになりました。権限を絞る動機とコストを抑える動機が、ここでようやく同じ向きを向いた感覚があります。
とはいえ私自身は、夜間の自動バッチはまだセッション単位に寄せたままです。無人で走るものほど、構成が途中で変わらない方が原因を追いやすいと考えているためです。会話途中の差し替えは、手元で長い調査をするときに限って試している段階です。
うまくいかなかった3つのこと
順調に畳めた話ばかり書いてきましたが、途中でつまずいた点も残しておきます。
許可集合をコード側にだけ持って、土台と乖離させました。 TOOL_ALLOWLIST をコードにベタ書きしたまま、土台の定義を Console から編集してツールを1つ外したことがあります。コード側の集合は古いまま。検証は通るのに、土台には存在しないツールを指定したセッションが作られる状態になりました。いまは起動時に土台を取得して、allowlist との差を突き合わせ、乖離があれば警告を出しています。境界を二重に持つなら、二重であることを検査する仕組みまで含めて設計するべきでした。
未定のフィールドを空配列で渡していました。 あるサイトのスキル構成がまだ決まっていなかった時期、skills に [] を入れて放置していました。空配列は「継承」ではなく「無し」です。結果として、記事の品質ゲートに当たるスキルが読まれないまま数本が走りました。未定なら空配列ではなくフィールドごと省略して継承させる。空配列は「無しにしたい」という意思表示のときだけ使う。この使い分けを、いまはコメントで明記しています。
固定したモデルの見直しを忘れました。 検証のために1サイトだけ model を明示して固定したところ、その後モデルを更新しても、固定した側だけが古いまま走り続けました。継承していれば自動で追随していた部分です。固定は便利ですが、固定した瞬間に「見直す責任」が発生します。いまは固定する側に、固定した理由と見直す時期をコメントで書き添えるようにしました。理由の書けない固定は、そもそも継承でよいはずだ、という判断基準にもなっています。
3つとも、根っこは同じでした。オーバーライドは黙って効くので、間違っていても例外が飛んできません。だからこそ、警告とログを自分で足す手間を惜しまない方が、結局は早いというのが実感です。
どう使い分けるか
状況別の私なりの結論です。差し替えを導入する前に、私は次の3点を先に決めています。
どのフィールドを土台に残し、どれをオーバーライドへ出すか(変更頻度で線引きする)
許可集合(ツール・MCP)の上限をどこに置き、何を境界として落とすか
セッションごとの構成をどのログに残し、日次でどう棚卸しするか
この3点を先に決めておくと、権限事故を回避しやすくなります。
土台が2〜3個で、差分が大きいなら、無理にオーバーライドへ寄せず別定義のままで構いません。差し替えの検証コストが、同期コストを上回ります。
一方、土台が実質1つで、差分が「プロンプトの一部・コネクタ・スキル」に収まるなら、セッションオーバーライドが効きます。私の4サイトはまさにこの形でした。
そして差し替えを入れるなら、検証と記録は最初から組み込むこと。オーバーライドは強力なぶん、黙って権限や構成を変えてしまいます。落とすべき境界を assertSubset のような形で先に決めておくと、夜間の自動実行を安心して任せられます。
私自身、まだ運用しながら勘所を掴んでいる途中です。同じように複数の似たエージェントを抱えている方の、設計を畳むきっかけになればうれしく思います。お読みいただきありがとうございました。