設定ブロックを書き足せば済む、と思っていました
Managed Agents のプロトタイプを本番想定へ寄せる作業を始めたとき、私は agents.create() の引数に sandbox や memory や audit といったブロックを足していけば形になる、と考えていました。設定は一箇所に集まっているはずだ、という思い込みです。
実際には、そのブロックはどれも存在しませんでした。
手元で確かめたほうが早いので、SDK を入れて型を直接読みました。@anthropic-ai/sdk 0.120.0 で AgentCreateParams のトップレベルに並んでいたのは、次の10個だけです。
| フィールド | 役割 |
name / description | 人間が読むための識別子 |
model | モデルID。文字列、または id と speed を持つオブジェクト |
system | システムプロンプト |
tools | 組み込みツールセット / MCP ツール / カスタムツール |
mcp_servers | MCP サーバー接続 |
skills | スキル参照 |
multiagent | 委任先のロスター |
metadata / betas | 任意のキー値 / ベータヘッダ |
sandbox・memory・observability・optimization・audit・permissions・checkpoint・lifecycle。いずれも0件でした。instructions もありません(system です)。
さらに client.agents そのものが undefined で、入口は client.beta.agents でした。client.beta.agents.batch・.pipelines・.secrets、client.beta.sessions.run も、すべて undefined を返します。
つまり「エージェント設定に全部書く」という設計図が、そもそも成立していなかったわけです。ここを取り違えたまま設計レビューまで進むと、実装フェーズで骨格ごと引き直すことになります。以下では、私がどこで境界を読み違えたのかを順に整理していきます。
なお、Managed Agents そのものが初めてという場合は、Managed Agents の基本概念と導入手順を先にご覧いただくとスムーズです。
骨格は Agent・Environment・Session の三つに割れている
設定が一箇所に集まらないのは、責務が三つのオブジェクトに分かれているからです。
| オブジェクト | 担うもの | 寿命 |
| Agent | モデル・システムプロンプト・ツール・MCP・スキル・委任先 | 永続。バージョン付き |
| Environment | コンテナをどう起こすか(実行基盤・ネットワーク方針) | 永続。使い回す |
| Session | 1回の対話。Agent と Environment を参照し、資材と鍵と予算を添える | 使い捨て |
エージェントループ自体は Anthropic 側のオーケストレーション層で回り、コンテナはツールが実行される場所として使われます。この分業を頭に入れておくと、「なぜサンドボックスの設定が Agent 側にないのか」が腑に落ちます。エージェントの人格と、その人格が手を動かす作業場は、別々に定義して組み合わせるものだからです。
最小の形はこうなります。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// 1. 作業場のテンプレート(使い回す)
const env = await client.beta.environments.create({
name: "report-workspace",
config: {
type: "cloud",
networking: { type: "unrestricted" },
},
});
// 2. エージェント(永続・バージョン付き。リクエストパスで作らない)
const agent = await client.beta.agents.create({
name: "sales-report-agent",
model: "claude-sonnet-4-6",
system: "月次営業レポートを生成します。数値は必ず添付データから引きます。",
tools: [{ type: "agent_toolset_20260401" }],
});
// 3. セッション(毎回。agent は ID で参照する)
const session = await client.beta.sessions.create({
agent: agent.id,
environment_id: env.id,
title: "2026-04 月次レポート",
});
ここで一つ、運用に効く注意があります。agents.create() をリクエストパスの中で呼ばないことです。エージェントは永続オブジェクトなので、1リクエストごとに作ると同名の設定が積み上がっていきます。生成した ID を保存し、以降は参照だけにします。
環境名も一意です。同じ名前で作り直そうとすると 409 が返ります。CI から冪等に流したい場合は、作成を試みて 409 なら既存を引く、という分岐を先に用意しておくと素直に回ります。
サンドボックスの制御は Environment 側にある
私が最初に agents.create() へ書こうとしたネットワーク制限は、Environment の config.networking に属していました。
const env = await client.beta.environments.create({
name: "restricted-workspace",
config: {
type: "cloud",
networking: {
type: "package_managers_and_custom",
allowed_hosts: [
"api.internal.example.com",
"mcp.internal.example.com",
],
},
},
});
方針は2種類です。unrestricted は法令上のブロックリストを除いて外向き通信を許し、package_managers_and_custom はパッケージマネージャに加えて allowed_hosts に挙げたホストだけを許します。
ここに、静かに刺さる落とし穴があります。制限側を選んだとき、allowed_hosts に MCP サーバーのドメインを入れ忘れる と、コンテナがそこへ到達できません。しかもツールは失敗を大きく喧伝せず、静かに機能しない形で終わります。エージェントの返答が妙に浅いのに例外は出ていない、という状況になったら、まずここを疑う価値があります。
config.type にはもう一つ self_hosted があります。エージェントループは Anthropic 側に置いたまま、bash やファイル操作を自分のインフラのコンテナで走らせる選択です。外向きポーリング型のワーカーが手元で動く構成になるため、networking ブロックは適用されません。出口の制御は自分で持つ、という前提に変わります。
規制の厳しい領域では、この self_hosted を選べるかどうかが導入可否そのものを分けることがあります。私はここを、セキュリティ要件の会話で最初に確認する項目に置いています。
ちなみに Managed Agents は第一者提供のみで、Amazon Bedrock・Google Vertex AI・Microsoft Foundry では利用できません。第三者プロバイダ経由で配信している場合は、Claude API とツール使用を自前で組む側へ倒すことになります。この一行を見落とすと、調達の段階で計画が止まります。
権限は「設定」ではなく「往復」で握る
permissions というフィールドを探して見つからなかったとき、私は権限設計そのものが弱いのかと一瞬思いました。これも読み違いでした。
権限はツール単位の permission_policy として宣言し、危険な操作は実行時にクライアントへ確認を返してくる、という往復の形になっています。
const agent = await client.beta.agents.create({
name: "guarded-agent",
model: "claude-sonnet-4-6",
system: "調査タスクを担当します。",
tools: [
{
type: "agent_toolset_20260401",
default_config: {
enabled: true,
permission_policy: { type: "always_allow" },
},
configs: [
// bash だけは毎回こちらの承認を要求する
{ name: "bash", permission_policy: { type: "always_ask" } },
],
},
],
});
always_ask を付けたツールが呼ばれると、セッションは session.status_idle へ落ちて止まります。こちらが user.tool_confirmation を返すまで先へ進みません。
// 確認要求に応答する(deny には理由を添えられる)
await client.beta.sessions.events.create(session.id, {
type: "user.tool_confirmation",
tool_use_id: pendingEventId,
result: "deny",
message: ".env ではなく .env.example を読んでください",
});
deny に添えた message はエージェントへ届き、方針を切り替える材料になります。ただ止めるのではなく、代わりに何を見てほしいかを書けるところが、実運用ではありがたい設計です。
逆向きに、既定を切って必要なツールだけ開ける書き方もできます。監査を受ける系のエージェントでは、私はこちらを選びます。許可した一覧が設定ファイルに列挙されているほうが、後から説明しやすいためです。
tools: [
{
type: "agent_toolset_20260401",
default_config: { enabled: false },
configs: [
{ name: "read", enabled: true },
{ name: "glob", enabled: true },
{ name: "grep", enabled: true },
],
},
]
永続メモリは別オブジェクトで、コンテナにはマウントとして現れる
memory: { short_term, medium_term, long_term } のような階層をエージェントに書く、というのも私の想像でした。実際には Memory Store という独立したオブジェクトです。
| オブジェクト | ID接頭辞 | 粒度 |
| Memory Store | memstore_ | ワークスペース単位のコレクション |
| Memory | mem_ | path で指す1テキストファイル(1件あたり 100KB 以下) |
| Memory Version | memver_ | 変更ごとの不変スナップショット |
作って、必要なら中身を先に積み、セッションの resources[] へ添えます。
const store = await client.beta.memoryStores.create({
name: "customer-context",
description: "顧客ごとの状態と過去の対応方針。作業前に必ず参照する。",
});
// 参照資料を先に置いておくこともできる
await client.beta.memoryStores.memories.create(store.id, {
path: "/escalation_policy.md",
content: "返金は 2 段階承認。金額が 5 万円を超える場合は担当者へ引き継ぐ。",
});
const session = await client.beta.sessions.create({
agent: agent.id,
environment_id: env.id,
resources: [
{
type: "memory_store",
memory_store_id: store.id,
access: "read_only",
instructions: "顧客対応の前に必ず確認してください。",
},
],
});
エージェント側からは、/mnt/memory/<ストア名>/ にマウントされたディレクトリとして見えます。専用のメモリツールはありません。read や write や grep といった普段のファイル操作でそのまま扱います。
この作りには、地味ですが効く含意があります。access: "read_only" はファイルシステムのレベルで読み取り専用になるため、プロンプトの言い回しで守るのではなく、書き込みが物理的に成立しません。共有ナレッジを壊されたくない場面では、ここを read_only に倒すだけで議論が一つ減ります。
制約も先に押さえておきます。メモリストアはセッション作成時にしか付けられません。sessions.resources.add() は memory_store を受け付けないので、対話の途中で「やはり参照させたい」と思っても後付けできません。1セッションあたり最大8ストアです。
私は最初、これを1つの大きなストアで済ませようとして、後から分けました。所有者と寿命が違うものを同じ箱に入れると、消していいのか判断できなくなるためです。共有の参照用を読み取り専用で1つ、利用者ごとの状態を読み書きで1つ。この二段構えが、いまのところ一番静かに回っています。
鍵をサンドボックスに入れない — Vault という境界
認証情報の扱いは、この設計で一番好きな部分です。
MCP サーバーの資格情報は Vault(vlt_ 接頭辞)に置き、セッション作成時に vault_ids で参照します。エージェント定義には鍵を書きません。
const session = await client.beta.sessions.create({
agent: agent.id,
environment_id: env.id,
vault_ids: ["vlt_abc123"],
});
重要なのは、Vault の資格情報はサンドボックスへ入らない ことです。MCP ツール呼び出しはリクエストがサンドボックスを出た後で Anthropic 側のプロキシを通り、そこで資格情報が付与されます。コンテナの中で動くコードは、エージェントが自分で書いたコードも含めて、Vault の中身を読めません。
プロンプトインジェクションを完全に無効化する仕組みではありませんが、「注入に成功しても鍵は持ち出せない」という一線が引かれます。境界の位置がはっきりしているぶん、リスク説明が具体的に書けます。
裏返しの制約もあります。現時点では、aws や gcloud や stripe のような認証済み CLI をサンドボックス内で直接動かす道が用意されていません。コンテナの環境変数を設定して資格情報を渡す口がないためです。該当サービスに MCP サーバーがあるならそちらへ寄せ、無ければホスト側のカスタムツールとして実装し、鍵は手元に留めたまま結果だけを返す、という組み方になります。
もう一点、実際に引っかかりやすい取り違えを書いておきます。MCP の認証トークンは、そのサービスの API キーとは別物です。ホスト型の MCP サーバーは概ね OAuth のベアラートークンを要求します。Notion の ntn_ で始まる連携トークンは Notion の REST API には通りますが、Notion の MCP サーバーの Vault 資格情報としては通りません。認証系統が違う、という理解が先に必要です。
さらに厄介なのは、資格情報が無効でもセッション作成は成功する ことです。失敗はイベントストリームの session.error として現れ、リトライは次の session.status_idle から session.status_running への遷移時に走ります。作成が通ったことを成功と見なして先へ進むと、原因の遠い場所で不可解な挙動に出会います。私はここで一度、半日ほど別の場所を疑って過ごしました。
マルチエージェントは coordinator の一段だけ
委任も、想像していた形とは違いました。sub_agents やパイプラインのステージ定義ではなく、コーディネータ側のトップレベルに multiagent を1つ置く形です。
const reviewer = await client.beta.agents.create({
name: "code-reviewer",
model: "claude-sonnet-4-6",
system: "差分をレビューし、修正が必要な箇所だけを列挙します。",
tools: [{ type: "agent_toolset_20260401" }],
});
const lead = await client.beta.agents.create({
name: "engineering-lead",
model: "claude-opus-4-7",
system: "作業を分解し、レビューは reviewer へ委任します。結果を統合して報告します。",
tools: [{ type: "agent_toolset_20260401" }],
multiagent: {
type: "coordinator",
agents: [
reviewer.id, // 文字列 = 最新バージョン
{ type: "agent", id: tester.id, version: 4 }, // バージョン固定
{ type: "self" }, // 自分自身の複製
],
},
});
// セッション側は何も変わらない。ロスターは agent 設定から解決される
const session = await client.beta.sessions.create({
agent: lead.id,
environment_id: env.id,
});
押さえておきたい制約が三つあります。
委任は1段だけです。 深さ2以上は無視されます。サブエージェントがさらに孫を呼ぶ木構造は組めません。設計としては、コーディネータの下に専門家が横並びになる形へ寄せることになります。
コンテナとファイルシステムは共有されます。 スレッドごとに分かれるのは会話コンテキストであって、作業場ではありません。複数のスレッドが同じパスへ書きにいく設計は、そのまま競合します。出力先をスレッドごとに分ける、という素朴な取り決めが効きます。
同時スレッドは最大25です。 ロスターに登録できるエージェントは20種まで、そのそれぞれを複数体スポーンできます。
進捗は session.thread_created や session.thread_status_idle といったイベントとしてセッションのストリームに流れます。サブエージェントが承認やカスタムツールの結果を必要とする場合、その要求は主スレッドへ転記され、session_thread_id が発生元を示します。監視すべきストリームが1本で済む、という設計になっています。応答するときは、その session_thread_id をそのまま添えて返します。
可観測性とコストは、セッションが自分で報告してくる
observability ブロックも存在しません。トレースは設定して吐かせるものではなく、イベントストリームを自分で受けて集約するものです。
ここで一つ、順序の作法があります。イベントを送る前にストリームを開く ことです。ストリームは開いた後に発生したイベントだけを配ります。状態の再送も履歴の再生もしません。先にメッセージを送ってから開くと、序盤の遷移がまとめて後追いで届き、リアルタイムに反応する余地が消えます。
// ストリームを先に開き、送信と並行させる
const stream = client.beta.sessions.events.stream(session.id);
await client.beta.sessions.events.create(session.id, {
type: "user.message",
content: "4月の売上レポートを作成してください。",
});
for await (const event of stream) {
if (event.type === "span.model_request_end") {
// モデル呼び出し1回分の区間。レイテンシ集計はここで取る
}
if (event.type === "session.status_idle") {
// stop_reason を見てから抜ける。無条件 break は取りこぼす
break;
}
}
そしてコストです。ここが今回、記述を最も大きく改めた箇所になります。
以前この記事には、ランタイム課金の単価と削減率の数字が書かれていました。出典を確認できなかったため、いずれも取り下げます。代わりに、推定ではなく実測で扱える経路を示します。
セッションオブジェクトの usage には、次のフィールドが含まれます。
| フィールド | 内容 |
list_cost | セッションの費用。金額オブジェクトとして返る |
active_seconds | いずれかのスレッドが running だった累積秒数。ランタイム費用が計算される対象 |
input_tokens / output_tokens | 累積トークン数 |
cache_read_input_tokens / cache_creation | キャッシュ読み出しと生成(有効期間別) |
server_tool_use | サーバー側ツールの使用量 |
見落としやすいのは、usage.active_seconds と stats.active_seconds が別物であることです。usage 側は同時に走った複数スレッドの重なりを1回として数え、stats 側は各スレッド自身の稼働時間を合算します。マルチエージェント構成では両者が乖離します。課金の側から読みたいなら usage を見ます。ここを混ぜると、並列化したときに費用が跳ねたように見えて、実際は見ている数字が違うだけ、ということが起こります。
上限を掛ける口も用意されています。budget をセッション作成時に渡します。
const session = await client.beta.sessions.create({
agent: agent.id,
environment_id: env.id,
budget: {
type: "limit",
// 金額は「補助単位の整数を文字列で」。"2500" は $25.00
max_list_cost: { amount: "2500", currency: "USD" },
},
});
amount の表現は、素通りしやすい割に痛い箇所です。浮動小数の丸めを一切入れないために文字列で、しかも補助単位で受け取ります。"25" と書くと 25 セントです。25 ドルのつもりで2桁分低い上限を掛けてしまい、セッションが早々に打ち切られる、という取り違えが起こり得ます。
個人開発だと、この種の桁の取り違えを下流で拾ってくれる人がいません。ここは定数に切り出し、コメントを添えて、自分が数週間後に読み返したときに必ず目に入る形にしています。
なお、コンテキストの圧縮・プロンプトキャッシュ・拡張思考は、いずれも既定で有効です。設定して有効化するものではありません。圧縮が走ったことは agent.thread_context_compacted イベントで分かります。長時間のセッションで挙動が変わったように感じたら、このイベントの出現位置を見ると説明がつくことがあります。
エージェントが /mnt/session/outputs/ へ書いたファイルは Files API 側で捕捉され、セッション ID で絞って取得できます。ここにも小さな作法があって、scope_id で絞る際は betas: ["managed-agents-2026-04-01"] を明示的に渡します。SDK の files リソースは Files API のヘッダしか自動付与しないため、指定しないと未知のフィールドとして弾かれることがあります。書き込み直後は1〜3秒ほど索引の遅れがあるので、空だったら1〜2回だけ待って引き直す作りにしておくと安定します。
移行するときに最初に確認したい五つ
プロトタイプから本番へ寄せる作業で、私が手戻りを生んだ順に並べ直すと、確認すべき点はこの五つに収れんしました。
第一に、書こうとしている設定がどのオブジェクトのものかを型で確かめること。 想像で書いたフィールドは静かに無視されるのではなく、設計そのものを別の場所へ連れて行きます。SDK を入れて .d.ts を読むのに数分しかかかりません。私はこの数分を惜しんで半日を失いました。
第二に、ネットワークを絞るなら MCP のドメインを許可リストへ入れること。 到達できないツールは、例外ではなく沈黙として現れます。
第三に、鍵は Vault に置き、サンドボックスへ渡さないこと。 そして、無効な資格情報でもセッション作成は通る、という前提でイベントストリームの session.error を見張ること。
第四に、メモリストアはセッション作成時にしか付けられないと知っておくこと。 後付けできない制約は、設計の順番そのものを規定します。
第五に、費用は推定せず usage から読むこと。 usage.active_seconds と stats.active_seconds の違いを踏まえたうえで、budget の amount が補助単位の文字列であることを、チームの誰か一人ではなく全員が知っている状態にしておくこと。
Managed Agents は、設定を一箇所に集める設計ではなく、責務ごとにオブジェクトを分けて組み合わせる設計です。慣れるまでは書く場所を探すことになりますが、分かれているぶん再利用が効きます。エージェントの人格を据え置いたまま作業場だけ差し替える、鍵の所在を作業場から切り離す、といった操作が自然にできるのは、この分離があってこそだと感じています。
私自身まだ手探りの部分が多く、ここに書いたのも一通りの読み解きにすぎません。実際に動かして違う結果に行き当たったという方がいらっしゃれば、そちらのほうが確かです。お読みいただきありがとうございました。
サブエージェントの分割設計をさらに詰めたい場合は、サブエージェントのファンアウト/ファンイン設計と部分失敗の扱いとClaude API 本番回復パターンも合わせてご覧ください。
本記事のフィールド一覧と挙動は、@anthropic-ai/sdk 0.120.0 の型定義および実際のクライアントオブジェクトを確認したうえで記述しています。ベータ期間中の仕様であり、変更される可能性があります。