Claude Managed Agents make you commit up front. The agent is a persisted, versioned object — model, system prompt, tools, MCP servers, skills all live on it — and every session references it by ID. That is the right default: it gives you reproducibility and rollback. It is also, occasionally, in your way.

You want to try Sol on one session to see if it's worth the price. You want to hand a debugging session one extra tool without granting it to production. You want to blank the system prompt to isolate whether it's causing a behavior. Minting a new agent version for each of these is heavy, and it pollutes the config every other session inherits.

The escape hatch is the third form of the session's agent field: agent_with_overrides. Here's how it works, and the two ways it will 400 on you.

The call#

The agent field on sessions.create() accepts three shapes. Most code uses the first two:

# 1. String shorthand — latest version of the agent
session = client.beta.sessions.create(agent=agent.id, environment_id=env.id)

# 2. Pinned to a specific version — reproducibility
session = client.beta.sessions.create(
    agent={"type": "agent", "id": agent.id, "version": agent.version},
    environment_id=env.id,
)

The third form overrides parts of the config for this session only:

# 3. Override — swap the model and clear the system prompt, this session only
session = client.beta.sessions.create(
    agent={
        "type": "agent_with_overrides",
        "id": agent.id,
        # "version": agent.version,   # optional; omitted = latest, same as the other forms
        "model": "claude-opus-4-8",   # replace the agent's model for this session
        "system": None,               # clear the system prompt for this session
    },
    environment_id=env.id,
)

You can override any of model, system, tools, mcp_servers, skills. The agent resource is not touched and no new version is created. The returned session.agent reflects the post-override config, but its id and version still point at the base agent — so the session traces back to where it started. That traceability is the whole reason to reach for this instead of spinning up a throwaway agent for every experiment.

The SDK sets the required managed-agents-2026-04-01 beta header automatically. In raw HTTP you send it yourself.

The tri-state rule — and why "add a tool" is a trap#

Each overridable field has three states:

That last word is the one that bites. Overrides never merge. If you override tools, the array you pass is the session's entire tool set — the agent's tools are not appended:

# WRONG — you think you're adding Linear; you've actually dropped every base tool
agent={"type": "agent_with_overrides", "id": agent.id,
       "tools": [{"type": "mcp_toolset", "mcp_server_name": "linear"}]}

# RIGHT — list the base tools you want to keep, plus the new one
agent={"type": "agent_with_overrides", "id": agent.id,
       "tools": [
           {"type": "agent_toolset_20260401"},
           {"type": "mcp_toolset", "mcp_server_name": "linear"},
       ]}

The two 400s#

Clearing works cleanly for system, mcp_servers, and skills. Two exceptions return 400 invalid_request_error:

  1. model is never clearable. model: null returns agent_model_required. A session must run some model — override it or inherit it, but you cannot blank it.
  2. You can't clear tools while skills is non-empty. Skills require the read tool to function, so tools: null / tools: [] returns a 400 whenever the session's effective skills list is non-empty. Clear the skills too, or leave a read-capable toolset in place.

After the session starts, most fields freeze#

Overrides apply at create time. Once a session exists, the surface you can still change is narrow: sessions.update() can replace agent.tools, agent.mcp_servers, and vault_ids — session-local, full-replacement (GET the session, modify the array, POST it back to preserve entries), and the session must be idle (interrupt it first if it's running):

client.beta.sessions.update(
    session.id,
    agent={
        "tools": [
            {"type": "agent_toolset_20260401"},
            {"type": "mcp_toolset", "mcp_server_name": "linear"},
        ],
        "mcp_servers": [
            {"type": "url", "name": "linear", "url": "https://mcp.linear.app/sse"},
        ],
    },
)

model and skills are fixed for the session's lifetime — they can only be set via agent_with_overrides at creation. The agent's configured system is fixed too. The one way to shift behavior mid-run is a system.message event, which replaces the effective system prompt between turns (Claude Opus 4.8):

client.beta.sessions.events.send(
    session.id,
    events=[{
        "type": "system.message",
        "content": [{"type": "text", "text": "The user's timezone is America/New_York."}],
    }],
)

When to reach for it#

The line is simple. If a change should apply to this run only — an experiment, a debug session, a one-off model trial — override it, and the shared agent config stays clean. If it should apply to every future run, update the agent with POST /v1/agents/{id}, which creates a new version that new sessions pick up (and existing sessions keep their pinned version, so nothing in flight breaks).

That split — session-local overrides for the disposable, agent versions for the durable — is what lets you experiment against live traffic without turning your agent registry into a graveyard of near-duplicate configs. For the versioning model those durable changes flow through, and the sub-agent roster overrides compose with, see hierarchical subagents in the Claude Agent SDK.