Skip to content

Latest commit

 

History

History
621 lines (538 loc) · 32.5 KB

protocol-v2.md

File metadata and controls

621 lines (538 loc) · 32.5 KB

Protocol V2

The core SDK can opt into the draft ACP protocol v2 surface with the unstable_protocol_v2 crate feature:

agent-client-protocol = { version = "...", features = ["unstable_protocol_v2"] }

This feature is separate from the broad unstable feature because protocol v2 is a versioning experiment, not just an unstable method family.

To start from working code, build and run the companion agent and client in the Runnable Protocol V2 Quickstart. The examples exercise prompt acceptance, independent session updates, and the terminal idle state over a real stdio connection.

JSON-RPC batches

Batch framing is a shared JSON-RPC transport feature, not a v2-only protocol feature. Both v1 and v2 accept incoming batches, preserve them through relays, and group replies into one response array. The SDK does not originate batches of requests or notifications. See Transport Architecture: JSON-RPC Batch Behavior for the complete rules.

By default, Client.builder(), Agent.builder(), and Proxy.builder() continue to expose the stable v1 API. To use the v2 API for a connection, construct the builder with Client.v2(), Agent.v2(), or Proxy.v2(). Fluent typed handlers, spawned tasks, close callbacks, and connect_with receive V2ConnectionTo<_>, so the protocol version is reflected in the high-level Rust API as well as on the wire:

use agent_client_protocol::schema::{ProtocolVersion, v2};
use agent_client_protocol::{Agent, Client};

fn implementation() -> v2::Implementation {
    v2::Implementation::new("example", "0.1.0")
}

# async fn run(agent_transport: impl agent_client_protocol::ConnectTo<agent_client_protocol::Client>) -> agent_client_protocol::Result<()> {
Client
    .v2()
    .connect_with(agent_transport, async |cx| {
        let initialize = cx
            .send_request(v2::InitializeRequest::new(
                ProtocolVersion::V2,
                implementation(),
            ))
            .block_task()
            .await?;

        assert_eq!(initialize.protocol_version, ProtocolVersion::V2);
        Ok(())
    })
    .await?;
# Ok(())
# }

# async fn serve(client_transport: impl agent_client_protocol::ConnectTo<agent_client_protocol::Agent>) -> agent_client_protocol::Result<()> {
Agent
    .v2()
    .on_receive_request(
        async |initialize: v2::InitializeRequest, responder, _cx| {
            responder.respond(v2::InitializeResponse::new(
                initialize.protocol_version,
                implementation(),
            ))
        },
        agent_client_protocol::on_receive_request!(),
    )
    .connect_to(client_transport)
    .await?;
# Ok(())
# }

When v2 mode is enabled, application code should use types from agent_client_protocol::schema::v2. The flat agent_client_protocol::schema::* exports remain the stable v1 schema. This will likely change as v2 gets closer to release. The preceding agent fragment demonstrates version negotiation only. An agent that advertises session support must also implement the complete baseline session surface shown in the runnable quickstart.

High-level v2 sessions

Stable callbacks receive ConnectionTo<_> and expose the protocol v1 build_session*, SessionBuilder, ActiveSession, and SessionMessage APIs. Callbacks installed through Client.v2() receive V2ConnectionTo<_> and expose the v2 build_session* and resume_session* helpers, plus feature-gated fork_session* helpers when unstable_session_fork is enabled. The shared names describe the same lifecycle operations while the connection type selects their schema and return types at compile time. Resume and fork return V2ResumeSessionBuilder and V2ForkSessionBuilder. Call start_session to publish the request and obtain an OpenedV2Session containing the command handle and complete operation-specific response.

Low-level custom with_handler and with_runner implementations continue to receive the protocol-neutral ConnectionTo<_>, and generic send_request remains schema-agnostic. These generic APIs do not infer a protocol version from the Rust payload type: callers on a v2 connection must use schema::v2 types, or deliberately send extension or untyped messages. The connection guard enforces negotiation and initialization lifecycle, not Rust-type provenance. Dynamic handlers registered through V2ConnectionTo::add_dynamic_handler use the same low-level HandleDispatchFrom interface and therefore also receive ConnectionTo<_>.

Nested connections preserve the stable ConnectionTo API while still typing the child implementation's callbacks. On a raw ConnectionTo<_>, spawn_connection(Client.v2(), transport) returns a raw ConnectionTo<_>, and callbacks installed on that v2 child builder still receive V2ConnectionTo<_>. Existing spawn_connection::<Role> calls therefore remain source-compatible.

When a raw parent also needs a typed handle to the v2 child, the unstable_protocol_v2 feature exposes ConnectionTo::spawn_connection_with_context, which returns the context selected by the child builder. V2ConnectionTo::spawn_connection likewise follows the child builder naturally, so spawning Client.v2() through an already-typed v2 connection returns another V2ConnectionTo<_>.

A complete client installs update and interactive-request handlers before connecting. After session/prompt is accepted, it must keep the connection alive and consume updates until the matching session reaches idle. The v2_one_shot_client example demonstrates the full sequence, while the compiled cookbook v2_one_shot_prompt recipe shows how to embed it in an application. Permission handlers should transfer the request and responder to application-owned work rather than waiting for user input inside the dispatch callback.

V2 deliberately separates prompt submission from session observation:

  • session/prompt returns a PromptResponse as soon as the agent accepts the prompt. Its message_id identifies the inserted user message, and live UserMessage updates for that prompt use the same ID. Persisting the message in resumable history remains optional. The response does not indicate turn completion. V2Session::send_prompt returns that request as a SentRequest<PromptResponse>. Callers must explicitly await it, register a response callback, or detach it.
  • V2SessionBuilder::start_session likewise returns a mapped SentRequest. Its OpenedV2Session result keeps the command handle separate from the complete NewSessionResponse represented by the linked schema, rather than reconstructing a selected subset of its fields.
  • V2ConnectionTo::resume_session and resume_session_from return a V2ResumeSessionBuilder. Its start_session method publishes session/resume and returns an OpenedV2Session containing the complete ResumeSessionResponse without reconstructing it.
  • With unstable_session_fork, V2ConnectionTo::fork_session and fork_session_from return a V2ForkSessionBuilder. Its start_session publishes session/fork, preserves the complete ForkSessionResponse, and uses that response's newly allocated session ID for the command handle.
  • V2Session is a cloneable command handle containing only the session ID and connection. It does not own, buffer, or unregister inbound messages.
  • Register typed UpdateSessionNotification and RequestPermissionRequest handlers on Client.v2() before connecting. Updates and interactive requests are separate protocol lanes. Permission handlers should transfer responders to application-owned work rather than waiting for user input inside the connection dispatch loop. Without matching handlers, unhandled v2 notifications are ignored and unhandled requests receive a method-not-found response. They are not retained for a later per-session receiver.
  • session/update events can arrive before, during, or after a prompt request. They carry a session ID and entity IDs, but no prompt or turn ID. The SDK therefore does not attribute intervening events to a locally submitted prompt or provide a prompt-scoped text accumulator.
  • state_update describes the session-wide foreground state. idle means the session can accept ordinary new foreground work. It is not a wire-level boundary assigning previous events to one prompt, and background updates may continue while idle.
  • cancel_active_work sends session-wide session/cancel. Cancellation of active foreground work completes after the required idle update with stop reason cancelled; cancelling an idle session need not emit another completion. The client should immediately mark unfinished tool calls for the active work as cancelled and must resolve every pending permission request in that session with the cancelled outcome, without answering twice or affecting other sessions. Elicitations are not all cancelled implicitly: agents should use individual $/cancel_request for interactions made irrelevant by the cancelled work. Cancelling or dropping the prompt's SentRequest is the separate JSON-RPC request-cancellation mechanism; insertion still requires a successful prompt response with its message ID.
  • set_config_option returns the authoritative replacement option set, and close returns the complete close response. Mutable configuration is not cached on the command handle.

Install connection handlers before session/new, session/resume, and feature-gated session/fork requests. This is especially important before calling start_session on a V2ResumeSessionBuilder: replay updates precede the resume response on the wire, so preinstalled typed handlers observe them in order. If a handler forwards updates to another task, the application is responsible for any additional projection-drained barrier it needs before treating replay as locally applied. See Ordered Application Dispatch for a single-consumer queue that carries updates, response results, and closure. For an application-owned prototype that additionally coordinates shared resume replay, abandonment, and close, see Session Operation Coordination. It is a cookbook policy example, not a public SDK coordinator.

Dropping command handles has no network or inbound-routing side effect. For a session configured with V2SessionBuilder::with_mcp_server or V2ResumeSessionBuilder::with_mcp_server, or feature-gated V2ForkSessionBuilder::with_mcp_server, the SDK installs the MCP routes and initially polls their runner tasks before publishing the corresponding setup request, so the agent can connect to those servers during setup or resume replay. Runners may continue asynchronous initialization, so custom connectors must be able to queue connections and messages once constructed. A successful setup promotes the attachment to the connection lifetime. A setup failure, including an error response after cancellation, cleans up the pending attachment. This attachment requires both unstable_protocol_v2 and unstable_mcp_over_acp. Fork additionally requires unstable_session_fork.

A v2 proxy can instead attach one server globally with Proxy.v2().with_mcp_server(...). The proxy reuses one connection-scoped server ID and adds its declaration to v2 session/new, session/resume, and feature-gated session/fork requests. It modifies only the mcpServers field, preserving unrelated setup fields and extensions for downstream handlers.

V2SessionBuilder::on_proxy_session_start and V2ResumeSessionBuilder::on_proxy_session_start, plus V2ForkSessionBuilder::on_proxy_session_start when enabled, are the non-blocking setup helpers for a v2 proxy:

use agent_client_protocol::schema::v2;
use agent_client_protocol::{Client, Proxy};

Proxy
    .v2()
    .on_receive_request_from(
        Client,
        async |request: v2::NewSessionRequest, responder, cx| {
            cx.build_session_from(request)
                .with_mcp_server(session_server)?
                .on_proxy_session_start(responder, async |opened| {
                    let (session, setup_response) = opened.into_parts();
                    record_session(session.session_id(), setup_response);
                    Ok(())
                })
        },
        agent_client_protocol::on_receive_request!(),
    );

These helpers forward request cancellation, send an ordered downstream setup request, and forward the complete operation-specific response without reconstruction. For session/new and session/fork, routing is installed when the response makes the new session ID available and before later inbound traffic is dispatched. Fork routing uses the response's new ID rather than the source session ID. For session/resume, the ID is already known, so routing and any per-session MCP attachment are ready before the downstream request is published. Replay updates can therefore be forwarded upstream before the complete ResumeSessionResponse, as required by the protocol. A failed or cancelled downstream response drops pending routing and MCP attachment. Successful setup keeps them for the connection lifetime. A cancellation signal itself remains advisory: it is forwarded downstream while the helper awaits that response. Each helper then spawns the callback outside the ordering barrier with an OpenedV2Session containing the command-only session handle and complete setup response. Updates and interactive requests remain independent connection traffic and should still be handled by typed callbacks on Proxy.v2().

If an application wants stream ergonomics, it can fan typed updates out from the connection handler with an explicit buffering and subscriber policy.

Conductor and proxy initialization

Proxy authors should make the version boundary explicit. Proxy.builder() is the stable v1 builder, while Proxy.v2() is v2-only and requires _proxy/initialize to select protocol v2. A proxy built for one version rejects the other version instead of parsing it through a permissive schema.

When one component must expose independently authored v1 and v2 proxies, compose them with Proxy.protocol_router().with_v1(v1_proxy).with_v2(v2_proxy). The conductor has already selected and canonicalized the protocol before _proxy/initialize, so the proxy router requires an exact v1 or v2 match, preserves the complete initial transport frame, and does not downgrade or convert later traffic.

Components implementing their own raw version selector can use Proxy.builder().without_acp_version_guard() and keep protocol-neutral ConnectionTo callbacks. This disables the SDK's automatic version guard and is not a substitute for selecting Proxy.v2() in an ordinary v2 proxy implementation.

Enable unstable_protocol_v2 on agent-client-protocol-conductor to carry a v2 connection through a conductor proxy chain. The conductor inspects the raw protocolVersion before parsing initialization, rewrites ordinary initialize to _proxy/initialize without reserializing its parameters, and restores the ordinary method before the request reaches the final agent. For an exact v2 request, info, capabilities, metadata, and unknown extension fields therefore retain their wire shape across conductor-controlled rewrites. A proxy implementation can still deliberately replace the request it forwards.

An exact v2 request can retain unknown raw fields, while a request for a later compatible version is canonicalized through the selected v2 schema before component instantiation.

Proxy implementations use agent_client_protocol::schema::v2::InitializeProxyRequest. Its response is the v2 InitializeResponse. The flat schema::InitializeProxyRequest remains the stable v1 type. Static conductor component providers can carry either selected schema, but each supplied component must support that version. Use Agent.protocol_router() or Proxy.protocol_router() when a static component has separate implementations. Custom InstantiateProxiesAndAgent and InstantiateProxies implementations opt into v2 by implementing their feature-gated v2 method. The default rejects v2 rather than interpreting it as v1. Returning the initialize request unchanged preserves its complete raw parameters for an exact-version request, including unknown extensions. Returning a modified typed request makes that serialized request authoritative. The conductor pins protocolVersion to its selected implementation even if an instantiator attempts to change it, and validates the final agent's initialize response against that selection. The proxy connection also routes v2 session/new requests and responses without interpreting them as v1 payloads.

MCP compatibility polyfill

The concrete agent_client_protocol_polyfill::mcp_over_acp::McpOverAcpPolyfill can participate in a v2 conductor chain when its unstable_protocol_v2 feature is enabled. It selects v1 or v2 from _proxy/initialize, uses that version's MCP capability and wire types, and adapts native McpServer::Acp declarations in v2 session/new, session/resume, and feature-gated session/fork requests. Other declarations and unrelated request fields remain unchanged. See MCP-over-ACP Compatibility Bridge for placement and feature configuration.

This feature extends the concrete compatibility proxy only. The core SDK's global MCP attachment and proxy-session helpers support v1 and v2 independently, as described above.

Version routing

The SDK handles the initialize negotiation at the JSON-RPC boundary:

  • Native Client.v2() and Agent.v2() connections reject ordinary protocol traffic until the initialization response completes. $/cancel_request remains available while initialization is in progress. The client is the initializer and the agent is the responder. Attempts in the opposite direction are rejected. An initialization error leaves the connection uninitialized so the client can retry, while a second initialization after a successful handshake is rejected.
  • A v2 client advertises protocol v2 as its latest supported version.
  • A v2 client requires a v2 agent. If the agent responds with v1, the initialize request resolves with an error and the caller must explicitly fall back to a v1 client implementation if that is acceptable.
  • A native v1 agent selects v1 for requests advertising v1 or a newer version, with or without unstable_protocol_v2 enabled. Newer initialization metadata is normalized into v1 identity and shared capabilities. The SDK never enables v1 file-system or terminal-execution capabilities from unknown newer fields. V1's original fallback-to-latest rule is retained, including a v1 selection when the client requests an unsupported older version.
  • A standalone v2 agent accepts a request for v2 or a newer version with compatible initialization parameters and selects v2. The request delivered to the handler has protocol_version normalized to v2, and the response selects v2. If a client initializes with v1, the request resolves with -32022 (Unsupported protocol version) and data: { "requested": 1, "supported": [2] }. The v2 initialization handler is not invoked. Use an agent router with a v1 implementation to serve v1 sessions.
  • If the agent responds with any other unsupported version, the request resolves with an error so the client can close the connection.
  • After initialization, the local API version and negotiated wire version must match. The SDK does not convert traffic between v1 and v2.

That means v1 and v2 implementations still need separate handlers. Agent.v2(), Client.v2(), and Proxy.v2() are v2-only. While protocol v2 stabilizes, the unstable_protocol_v2 crate feature also exposes Agent.protocol_router(), Proxy.protocol_router(), and Client.protocol_connector() for composing version-specific implementations.

Keep these protocol handlers as adapters around shared application logic, not as general-purpose conversions between wire types. In particular, v1 prompt responses end a turn, while v2 prompt responses acknowledge insertion and completion arrives through session state updates. Shared storage also does not automatically make sessions resumable through both versions. See the protocol's dual-stack migration guidance for adapter boundaries and a validation checklist.

Agents can add protocol implementations independently, which makes it easy for applications built with v2 support to control v2 rollout with a runtime feature flag:

use agent_client_protocol::schema::{v1, v2};
use agent_client_protocol::{Agent, ConnectTo};

# fn implementation() -> v2::Implementation {
#     v2::Implementation::new("example", "0.1.0")
# }
# async fn serve(client_transport: impl agent_client_protocol::ConnectTo<Agent>) -> agent_client_protocol::Result<()> {
# let enable_protocol_v2 = true;
let v1_agent = Agent.builder().on_receive_request(
    async |initialize: v1::InitializeRequest, responder, _cx| {
        responder.respond(v1::InitializeResponse::new(initialize.protocol_version))
    },
    agent_client_protocol::on_receive_request!(),
);

let agent = Agent.protocol_router().with_v1(v1_agent);

let agent = if enable_protocol_v2 {
    let v2_agent = Agent.v2().on_receive_request(
        async |initialize: v2::InitializeRequest, responder, _cx| {
            responder.respond(v2::InitializeResponse::new(
                initialize.protocol_version,
                implementation(),
            ))
        },
        agent_client_protocol::on_receive_request!(),
    );

    agent.with_v2(v2_agent)
} else {
    agent
};

agent
    .connect_to(client_transport)
    .await?;
# Ok(())
# }

The agent protocol router reads the initial initialize request and selects a version-specific implementation. Endpoints exposing v2 select the highest configured version at or below the requested version, or reject initialization with a structured version-mismatch error if none exists. V1-only endpoints retain v1's original fallback-to-latest rule. The router normalizes a v2 initialize request when selecting a v1 implementation, but does not convert messages between v1 and v2 after routing. For compatibility, the initial frame may be a batch whose first call-shaped entry is initialize. The router preserves the complete frame when handing it to the selected implementation. Response-only frames before initialization are ignored.

Version mismatch errors

A v2-only endpoint rejects a v1 request without requiring v2's Client identity or manufacturing a higher-version success response. The error code is -32022, and its data contains requested and the endpoint's supported versions. This diagnostic is separate from malformed initialization parameters, which remain -32602. The router preserves response IDs and batch framing, flushes the rejection, and closes without starting the selected application.

Custom endpoints can construct the same wire error with agent_client_protocol::util::unsupported_protocol_version(requested, supported). The code is defined by v2 negotiation, not by a change to the v1 protocol. The shared SDK error model can preserve it as an unknown numeric code, so a legacy v1 client can handle the initialization error without new v1 schema definitions.

just test runs the negotiation tests in both the all-features workspace and the core crate without default features, so v1 negotiation does not depend on whether the draft v2 API is compiled in.

Clients should close after a version mismatch and display the supported versions. A successful downgrade must also be checked against the Client's actual supported versions, because advertising a latest version does not imply support for every older version.

Proxy and client routing

The proxy protocol router reads _proxy/initialize after the conductor has selected the chain's wire version. It therefore requires an exact configured v1 or v2 implementation instead of negotiating or downgrading. It validates the selected schema, then hands the complete, unchanged initial frame to that strict implementation.

Clients use a connector because fallback may require opening a new transport. Both client implementations and the agent transport are factories:

use agent_client_protocol::Client;

let connector = Client
    .protocol_connector()
    .with_v1(|| v1_client())
    .with_v2(|| v2_client());

connector.connect_to(|| open_agent_transport()).await?;

The connector starts the highest configured implementation. If a successful v2 initialize response negotiates v1 and a v1 implementation is configured, the connector starts the v1 implementation and compares the complete initialize parameters it would send with the normalized v2 request already seen by the agent:

  • If they match exactly, the connector reuses the current agent connection and delivers the original response to the v1 implementation with its request ID. It does not send a second initialize request.
  • If they differ, the connector closes that connection, calls both factories again as needed, and performs a fresh v1 initialization on a new agent connection.
  • If the agent rejects the v2 initialize request, the error is surfaced. A rejected initialize is not treated as permission to retry with v1.

The reuse probe is conservative: if parsing and serializing the raw v2 request would change any parameter, reuse is disabled and fallback opens a fresh connection. That does not turn an otherwise valid v2 request into an error.

Schema changes in schema 1.5 through 1.12

The unstable_protocol_v2 API follows the moving draft schema. Schema 1.5 added semantic newtypes for paths, media types, IDs, and cursors, renamed DiffPatch.diff to DiffPatch.text, and added terminal state and output update types. Schema 1.7 removed the former schema-wide v1/v2 conversion API: versioned implementations should remain separate, with purpose-specific adapters at runtime boundaries where the required state and policy are available. These are draft API changes rather than stable v1 wire changes. See Migrating to v2.0 for concrete source changes.

Schema 1.6 adds Cancelled tool-call and plan-entry statuses to draft v2.

Schema 1.7 stabilizes elicitation and terminal authentication, so neither surface requires its former SDK feature flag. It also adds context compaction updates (stable since schema 1.11). The SDK carries them through its existing typed session/update routing in both protocol versions. V1 clients advertise compaction support through ClientSessionCapabilities::compaction.

Schema 1.8 stabilizes optional programmatic tool-call names in both protocol versions. No tool-name-specific feature is required. Draft v2 users only need unstable_protocol_v2. In v1, an omitted or null name leaves the existing value unchanged on updates. In v2, omission leaves it unchanged, null clears it, and a string replaces it. V1 cannot express the explicit v2 null clear operation.

Schema 1.11 stabilizes session notices and compaction. No surface-specific feature is required. Draft v2 users still need unstable_protocol_v2.

In draft v2, failures after a prompt's user message is inserted end active work with StopReason::Error(ErrorStopReason) on the idle state update, optionally including a JSON-RPC error object. Failures before insertion remain error responses to session/prompt. StopReason now serializes as an object tagged by stopReason, flattened into IdleStateUpdate. Its Other variant takes OtherStopReason rather than a string and preserves unknown payload fields. V1 prompt responses are unchanged and do not accept an error stop reason.

Schema 1.11 also rejects malformed payloads that receivers act on rather than silently dropping invalid entries or substituting defaults. This includes session setup's MCP servers and additional directories, terminal launch arguments and environment, and v2 replay cursors. Typed request decoding reports invalid parameters (-32602) without invoking the handler. null still means empty for the defaultable acted-on lists. Omission is accepted only where the field is optional. Capability and display metadata retain their lenient decoding.

Schema 1.12

Client-provided system prompts are available through the independent unstable_session_system_prompt feature. Use the complete lifecycle request with _from helpers and negotiate the agent's capability first; see Client-provided system prompts. Next Edit Suggestions, their document events, and their position-encoding capabilities have been removed from both protocol versions.

Foreground states are aggregate snapshots, not per-prompt events:

  • Report running when work starts, or requires_action if it starts blocked on user action. Agent-initiated workflows and local commands use the same lifecycle without needing a model call or a synthetic user prompt.
  • requires_action means the foreground workflow as a whole is blocked. A pending permission or elicitation does not imply this state while other foreground work can progress or when it belongs to background activity. Once reported, send running when progress resumes or idle when work ends.
  • When no foreground work remains, report idle; normally include a stop reason. Repeated snapshots, including an old stop reason, are idempotent, not additional completions, cancellations, or token usage.
  • New sessions default to idle, but updates can precede the new-session response. Do not overwrite them with idle when that response arrives.
  • Every successful resume must send a current-state snapshot after any replay and before its response, even without a replay cursor. Do not replay historical states as live activity or reset state to idle on the response. Testy and the echo example only resume sessions without foreground work, so their snapshot is idle without a new stop reason.

Prompt acceptance means logical user-message insertion with an ID and a position, not receipt or queueing. Running may precede insertion. Rejection before insertion requires an error response and no user-message updates for that submission; it does not alone end foreground work. A pre-insertion foreground failure can also require an error idle update. After insertion, the response stays successful and failure is reported on idle instead. This includes authentication failures: after insertion, auth_required (-32000) belongs in the error stop reason, not an error prompt response. Authenticating again does not restart ended work, and automatically retrying an inserted prompt would insert another message and could repeat side effects. When closing cancels active work, final updates and idle with cancelled precede the close response. An uninserted prompt cancelled by close gets -32800; an inserted one still gets its successful message ID.

Agents reject unsupported replay cursor types with -32602, rather than substituting another cursor. Retained user messages preserve the prompt response's ID across replay. Agent and thought message IDs may change across reconnections: rebuild that history on full replay instead of deduplicating only by ID. A chunk replay's empty-content primer clears only its replay ID.

Top-level _meta on upsert updates patches stored metadata by key: omission leaves it unchanged, null clears it, and an object preserves omitted keys, removes keys whose values are null, and replaces each other value whole. Nested objects are not merged; nested nulls are ordinary data. An empty object does not clear metadata. Replay uses the same rules and never implicitly resets metadata. ToolCallUpdate::apply_update and TerminalUpdate::apply_update apply this rule; use schema::v2::apply_meta_update for application-owned message, session, compaction, or subagent projections. Metadata on chunks, content, snapshots, and plan_update is not an entity patch.

Protocol v2 negotiation does not select every experimental feature. Operations such as provider configuration require their advertised capabilities. Informational notices, compaction, draft plan representations, subagent updates, and idle token usage can use their documented capability-free fallbacks; accepting an unknown variant is not a support advertisement. Unknown capability fields are ignored, while malformed known discriminators must not fall through to an unknown-variant fallback.

For v2 git_patch diffs, every path must be absolute and have no a/ or b/ prefix, including rename/copy header paths. Git quoting and /dev/null for an added/deleted file's missing side still apply. The SDK carries patch text; it does not rewrite paths or compute diffs from snippets.