●APPLY — ant CLI v1.30.0 introduces ant apply, which creates and updates agents, environments, skills, memory stores, and deployments straight from files in your repository●LOCKFILE — Commit the claude-lock.json it writes. Skip that step and every later run quietly creates fresh resources instead of updating the ones you already have●PLAN — ant apply prints a plan and waits for your approval before applying. If you intend to run it unattended in CI, decide how that approval is handled first●PRICING — The increase to $3 / $15 per MTok for Claude Sonnet 5 never happened. The introductory $2 / $10 is now simply the standard price●SOURCE — Prices and version numbers get garbled in secondhand coverage. It is worth checking the release notes on platform.claude.com before you quote either one●BETA — Agent Skills and the Skills API left beta, so the skills-2025-10-02 header is optional now. The Files API did too, but there the header still changes the response shape●APPLY — ant CLI v1.30.0 introduces ant apply, which creates and updates agents, environments, skills, memory stores, and deployments straight from files in your repository●LOCKFILE — Commit the claude-lock.json it writes. Skip that step and every later run quietly creates fresh resources instead of updating the ones you already have●PLAN — ant apply prints a plan and waits for your approval before applying. If you intend to run it unattended in CI, decide how that approval is handled first●PRICING — The increase to $3 / $15 per MTok for Claude Sonnet 5 never happened. The introductory $2 / $10 is now simply the standard price●SOURCE — Prices and version numbers get garbled in secondhand coverage. It is worth checking the release notes on platform.claude.com before you quote either one●BETA — Agent Skills and the Skills API left beta, so the skills-2025-10-02 header is optional now. The Files API did too, but there the header still changes the response shape
The Boundaries That Trip You Up Moving Claude Managed Agents to Production — Agent / Environment / Session, and Keeping Keys Out of the Sandbox
A production design walkthrough for Claude Managed Agents, checked against the SDK's actual type definitions. Covers which object owns sandbox control, persistent memory, credentials, delegation, and cost measurement.
When I started moving a Managed Agents prototype toward something production-shaped, my mental model was simple: keep adding blocks to agents.create() — a sandbox block, a memory block, an audit block — and eventually the shape would be right. Configuration lives in one place, surely.
None of those blocks exist.
Rather than keep guessing, I installed the SDK and read the types directly. In @anthropic-ai/sdk 0.120.0, the top level of AgentCreateParams holds exactly ten fields:
Field
What it carries
name / description
Human-readable identity
model
Model ID — a bare string, or an object carrying id and speed
system
System prompt
tools
Built-in toolset / MCP tools / custom tools
mcp_servers
MCP server connections
skills
Skill references
multiagent
Delegation roster
metadata / betas
Arbitrary key-values / beta headers
sandbox, memory, observability, optimization, audit, permissions, checkpoint, lifecycle — zero matches for every one of them. There is no instructions either; the field is system.
More fundamentally, client.agents is undefined. The surface lives under client.beta.agents. And client.beta.agents.batch, .pipelines, .secrets, and client.beta.sessions.run all return undefined as well.
So the blueprint I had in my head — "declare everything on the agent" — was never a real shape to begin with. Carry that misreading into a design review and you end up redrawing the skeleton during implementation. What follows is a walk through each boundary I misread, in the order the mistakes cost me time.
Configuration doesn't gather in one place because responsibility is split across three objects.
Object
Owns
Lifetime
Agent
Model, system prompt, tools, MCP servers, skills, delegation roster
Persistent, versioned
Environment
How the container is provisioned (runtime, network policy)
Persistent, reused
Session
One interaction. References an agent and environment; carries resources, vaults, budget
Disposable
The agent loop itself runs on Anthropic's orchestration layer. The container is where tools execute. Once that division clicks, the absence of sandbox settings on the agent stops feeling like an omission: the agent's persona and the workshop it operates in are defined separately and composed at session time.
The minimal shape looks like this:
import Anthropic from "@anthropic-ai/sdk";const client = new Anthropic();// 1. The workshop template (reused across sessions)const env = await client.beta.environments.create({ name: "report-workspace", config: { type: "cloud", networking: { type: "unrestricted" }, },});// 2. The agent — persistent and versioned. Never create this in the request path.const agent = await client.beta.agents.create({ name: "sales-report-agent", model: "claude-sonnet-4-6", system: "You produce monthly sales reports. Always source figures from attached data.", tools: [{ type: "agent_toolset_20260401" }],});// 3. The session — created per run, referencing the agent by IDconst session = await client.beta.sessions.create({ agent: agent.id, environment_id: env.id, title: "April 2026 monthly report",});
One operational note that matters more than it looks: don't call agents.create() inside your request path. Agents are persistent objects, so creating one per request quietly accumulates near-duplicate configs. Store the returned ID and reference it from then on.
Environment names are unique, too. Recreating one with an existing name returns a 409. If you want CI to run this idempotently, write the create-then-fall-back-to-lookup branch up front rather than discovering it on a Friday deploy.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Catch the wrong-object mistake early — knowing which of Agent / Environment / Session owns each setting, grounded in the SDK's own types rather than guesswork
✦Draw a precise line around what the Vault boundary protects, so MCP credentials never enter the sandbox and you can say exactly what that does and does not buy you
✦Measure runtime spend instead of estimating it, using the active_seconds and list_cost the session reports about itself
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
There are two policies. unrestricted permits egress except for a legal blocklist. package_managers_and_custom permits package managers plus whatever you list in allowed_hosts.
Here's the quiet trap. If you pick the restricted policy and forget to include your MCP server domains in allowed_hosts, the container can't reach them — and the tools don't announce this loudly. They fail silently. If your agent's answers feel oddly shallow while no exception is being raised, this is the first place worth checking.
config.type has a second value: self_hosted. The agent loop stays on Anthropic's side while bash, file operations, and code execution run in a container you control, driven by an outbound-polling worker. The networking block doesn't apply — egress becomes your responsibility.
In regulated environments, whether self_hosted is available often decides whether adoption happens at all. I've moved it to the top of my security-requirements checklist for exactly that reason.
One more line worth reading before procurement gets involved: Managed Agents is first-party only. It is not available on Amazon Bedrock, Google Vertex AI, or Microsoft Foundry. If you deploy through a third-party provider, you're building on Claude API plus tool use instead.
Permissions Are a Round Trip, Not a Setting
When I couldn't find a permissions field, I briefly assumed the permission model was thin. Another misread.
Permissions are declared per tool as a permission_policy, and dangerous operations come back to your client for confirmation at execution time.
The message on a denial is delivered to the agent, which can then adjust its approach. Being able to say what to look at instead — rather than just blocking — turns out to matter a lot in practice.
You can also invert the default and opt in per tool. For anything that will face an audit, I prefer this direction: an explicit allow-list in the config is far easier to explain months later than a set of exceptions.
Persistent Memory Is Its Own Object, Surfacing as a Mount
A memory: { short_term, medium_term, long_term } hierarchy on the agent was also something I invented. The real thing is a Memory Store — a separate object.
Object
ID prefix
Granularity
Memory Store
memstore_
Workspace-scoped collection
Memory
mem_
One text file addressed by path (≤ 100KB each)
Memory Version
memver_
Immutable snapshot per mutation
Create it, optionally seed it, then attach it through the session's resources[].
const store = await client.beta.memoryStores.create({ name: "customer-context", description: "Per-customer state and prior handling decisions. Check before any task.",});// Pre-load reference material before any session runsawait client.beta.memoryStores.memories.create(store.id, { path: "/escalation_policy.md", content: "Refunds require two-stage approval. Anything above $350 escalates to a human.",});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: "Consult this before responding to any customer.", }, ],});
From the agent's side, the store appears as a directory mounted at /mnt/memory/<store-name>/. There are no dedicated memory tools — the agent uses read, write, grep, and the rest of the ordinary file tooling.
That design has a small but useful consequence. access: "read_only" is enforced at the filesystem level, so a write doesn't fail because the prompt discouraged it — it fails because it can't happen. When you don't want shared knowledge mutated, flipping that one field removes an entire category of argument.
Two constraints worth internalizing early. Memory stores can only be attached at session creation.sessions.resources.add() does not accept memory_store, so you can't decide mid-conversation that the agent should have context after all. And you get a maximum of eight stores per session.
I initially tried to fit everything into one large store and later split it apart. Mixing content with different owners and different lifetimes makes it impossible to decide what's safe to delete. One read-only shared reference store plus one read-write per-user store has been the quietest arrangement so far.
Keeping Keys Out of the Sandbox — the Vault Boundary
Credential handling is my favorite part of this design.
MCP credentials live in a Vault (prefix vlt_) and are referenced at session creation through vault_ids. No key ever appears in an agent definition.
The important property: vault credentials never enter the sandbox. MCP tool calls leave the sandbox first and pass through an Anthropic-side proxy, which attaches the credential on the way out. Code running inside the container — including code the agent writes itself — cannot read a vaulted secret.
This doesn't make prompt injection harmless. It does draw a firm line: a successful injection still can't walk away with the key. A boundary you can point at is a boundary you can write a risk assessment about.
The flip side is a real constraint. There is currently no way to run authenticated CLIs like aws, gcloud, or stripe directly inside the sandbox, because there's no mechanism to set container environment variables from a vault. If the service has an MCP server, route through that. If not, implement a host-side custom tool that keeps the key on your machine and returns only results.
One more mix-up that catches people. An MCP auth token is not the same thing as that service's API key. Hosted MCP servers generally expect OAuth bearer tokens. A Notion ntn_ integration token authenticates fine against Notion's REST API and will not work as a vault credential for Notion's MCP server. Different auth systems entirely.
And the subtle one: an invalid credential does not block session creation. The session comes up successfully; the failure surfaces as a session.error event in the stream, and auth retries on the next session.status_idle → session.status_running transition. Treat a successful create as proof that auth worked and you'll debug the wrong layer for a while. I spent half a day doing precisely that.
Delegation Goes Exactly One Level Deep
Multi-agent work also looked different from what I'd imagined. There's no sub_agents block and no pipeline stage definition — just a single top-level multiagent field on the coordinator.
const reviewer = await client.beta.agents.create({ name: "code-reviewer", model: "claude-sonnet-4-6", system: "Review the diff and list only what must change.", tools: [{ type: "agent_toolset_20260401" }],});const lead = await client.beta.agents.create({ name: "engineering-lead", model: "claude-opus-4-7", system: "Break work down, delegate review to the reviewer, and synthesize the result.", tools: [{ type: "agent_toolset_20260401" }], multiagent: { type: "coordinator", agents: [ reviewer.id, // bare string = latest version { type: "agent", id: tester.id, version: 4 }, // pinned version { type: "self" }, // copies of the coordinator ], },});// Nothing changes on the session — the roster resolves from the coordinator's configconst session = await client.beta.sessions.create({ agent: lead.id, environment_id: env.id,});
Three constraints shape the design.
Delegation is one level only. Depth beyond that is ignored, so there's no tree of subagents spawning grandchildren. You end up with specialists arranged side by side beneath a coordinator.
The container and filesystem are shared. Threads isolate conversation context, not the workspace. Two threads writing to the same path will collide exactly as you'd expect. A plain convention — one output directory per thread — solves most of it.
Concurrency caps at 25 threads. The roster holds up to 20 distinct agents, and the coordinator can spawn multiple copies of each.
Progress arrives on the session stream as session.thread_created, session.thread_status_idle, and friends. When a subagent needs a tool confirmation or a custom tool result, that request is cross-posted to the primary thread with a session_thread_id identifying its origin — so you only ever watch one stream. Echo that session_thread_id back when you respond.
Observability and Cost Are Things the Session Reports About Itself
There's no observability block either. Traces aren't something you configure the platform to emit; they're something you assemble by consuming the event stream.
One ordering rule matters here: open the stream before you send events. The stream only delivers what happens after it opens — no state replay, no history. Send first and open second, and the early transitions arrive as one buffered clump, which removes any chance of reacting to them in real time.
// Open the stream first, then send concurrentlyconst stream = client.beta.sessions.events.stream(session.id);await client.beta.sessions.events.create(session.id, { type: "user.message", content: "Produce the April sales report.",});for await (const event of stream) { if (event.type === "span.model_request_end") { // One model inference span — collect latency here } if (event.type === "session.status_idle") { // Check stop_reason before breaking; an unconditional break drops work break; }}
Now, cost. This is the section I revised most heavily.
An earlier version of this article carried a specific runtime unit price and a specific savings percentage. I could not verify either against a primary source, so both are withdrawn. What follows is the measurable path instead.
The session object's usage carries these fields:
Field
Contents
list_cost
The session's cost, returned as a monetary amount object
active_seconds
Cumulative seconds with at least one thread running. This is what runtime cost is priced on
input_tokens / output_tokens
Cumulative token counts
cache_read_input_tokens / cache_creation
Cache reads and creations, broken down by cache lifetime
server_tool_use
Server-side tool usage
The easy thing to miss: usage.active_seconds and stats.active_seconds are not the same number. The usage figure counts overlapping activity from concurrent threads once. The stats figure sums each thread's own active time. In a multi-agent setup they diverge. If you're reasoning about billing, read usage. Conflate them and parallelizing will look like it blew up your costs when all that changed is which counter you were watching.
There's a ceiling you can set, too, passed at session creation:
const session = await client.beta.sessions.create({ agent: agent.id, environment_id: env.id, budget: { type: "limit", // Minor units, as an integer string. "2500" is $25.00 max_list_cost: { amount: "2500", currency: "USD" }, },});
That amount encoding slips past reviewers and stings when it does. It's a string so no float rounding is ever applied, and it's in minor units. Write "25" and you've set a twenty-five cent ceiling. Someone intending twenty-five dollars gets sessions cut short two orders of magnitude early.
Working as an indie developer, there is no downstream reviewer to catch an order-of-magnitude slip like that. I pull it into a named constant with a comment, so the version of me reading this back in three weeks cannot miss it.
Worth noting as well: context compaction, prompt caching, and extended thinking are all on by default. They are not flags you enable. When compaction runs, an agent.thread_context_compacted event tells you so — which often explains behavior that seems to shift partway through a long session.
Files the agent writes to /mnt/session/outputs/ are captured by the Files API and retrievable filtered by session. Small ritual here too: when filtering by scope_id, pass betas: ["managed-agents-2026-04-01"] explicitly. The SDK's files resource only auto-attaches the Files API header, so without it the request can be rejected on an unknown field. There's also a one-to-three second indexing lag after a write, so retry once or twice before concluding the list is empty.
Five Things Worth Checking First
Reordered by how much rework each one caused me, the migration checklist collapses to five items.
First, confirm which object owns the setting you're about to write. An invented field doesn't get politely ignored — it takes your design somewhere else entirely. Installing the SDK and reading the .d.ts takes a few minutes. I skipped those minutes and lost half a day.
Second, if you restrict networking, put your MCP domains in the allow-list. An unreachable tool presents as silence, not as an exception.
Third, keep keys in the Vault and out of the sandbox. And watch session.error on the stream, because an invalid credential still lets session creation succeed.
Fourth, remember that memory stores attach only at session creation. A constraint you can't work around after the fact dictates the order in which you design.
Fifth, read cost from usage rather than estimating it. Know the difference between usage.active_seconds and stats.active_seconds, and make sure the minor-unit string encoding of budget.amount is common knowledge on your team rather than one person's private trivia.
Managed Agents isn't built around a single configuration surface. It's built around separate objects you compose. Until that's habitual you'll spend time hunting for where a setting lives — but the separation is what makes reuse work. Swapping the workspace while keeping the agent's persona fixed, or moving credentials outside the workspace entirely, are natural operations precisely because these things were pulled apart.
I'm still finding my way through a lot of this, and what's above is one reading rather than the definitive one. If you've run it and landed somewhere different, your result is the more reliable one. Thank you for reading.
Field lists and behaviors above were checked against the type definitions and runtime client object of @anthropic-ai/sdk 0.120.0. This is a beta surface and subject to change.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.