Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Agent Client Protocol Rust SDK

This repository contains the Rust SDK for the Agent-Client Protocol (ACP).

For Users

If you want to build something with these crates, see the rustdoc:

The agent-client-protocol crate includes a concepts module that explains how connections, sessions, callbacks, and message ordering work.

For Maintainers and Agents

This book documents the design and architecture for people working on the codebase itself.

Repository Structure

src/
├── agent-client-protocol/              # Core protocol SDK
├── agent-client-protocol-http/         # HTTP/SSE/WebSocket transport
├── agent-client-protocol-rmcp/         # Integration with rmcp crate
├── agent-client-protocol-cookbook/     # Usage patterns (rendered as rustdoc)
├── agent-client-protocol-derive/       # Proc macros
├── agent-client-protocol-conductor/    # Conductor binary and library
├── agent-client-protocol-polyfill/     # MCP-over-ACP transport compatibility
├── agent-client-protocol-test/         # Test utilities and fixtures
├── agent-client-protocol-trace-viewer/ # Trace visualization tool
└── yopo/                               # "You Only Prompt Once" example client

Crate Relationships

graph TD
    acp[agent-client-protocol<br/>Core SDK, stdio, process spawning]
    http[agent-client-protocol-http<br/>HTTP/SSE/WebSocket transport]
    rmcp[agent-client-protocol-rmcp<br/>rmcp integration]
    conductor[agent-client-protocol-conductor<br/>Proxy orchestration]
    polyfill[agent-client-protocol-polyfill<br/>MCP transport compatibility]
    trace[agent-client-protocol-trace-viewer<br/>Trace visualization]
    cookbook[agent-client-protocol-cookbook<br/>Usage patterns]

    http --> acp
    rmcp --> acp
    conductor --> acp
    conductor --> trace
    polyfill --> acp
    cookbook --> acp
    cookbook --> rmcp
    cookbook --> conductor

Key Design Documents

Core Library Design

This document describes the design of the agent-client-protocol crate and its companion transport and integration crates.

For API usage, see the rustdoc and cookbook.

Crate Organization

agent-client-protocol

The core SDK. Provides:

  • Role types (Client, Agent, Proxy, Conductor) - the identities in ACP
  • Connection builders (builder(), connect_to(), connect_with())
  • Message handling (on_receive_request, on_receive_notification, on_receive_dispatch)
  • Protocol types (agent_client_protocol::schema::*) - all ACP message types
  • Transports and process launching (Channel, Lines, ByteStreams, Stdio, AcpAgent)
  • MCP server attachment - runtime-agnostic interfaces for wiring MCP servers into ACP sessions through the opt-in unstable_mcp_over_acp transport

agent-client-protocol-http

Optional HTTP/SSE and WebSocket clients and servers built on the core transport-frame boundary.

agent-client-protocol-rmcp

Integration with the rmcp crate:

  • McpServer::builder() - define MCP tools in Rust code
  • McpServer::from_rmcp() - wrap an rmcp server as an ACP MCP server

Standalone rmcp-backed servers need no ACP transport feature. Enable the integration crate’s unstable_mcp_over_acp feature to advertise an attached server as McpServer::Acp. Agents limited to HTTP MCP transports require the separate compatibility polyfill.

Role System

The type system is built around roles - the logical identity of an endpoint.

graph LR
    Client -->|connects to| Agent
    Agent -->|connects to| Client
    Proxy -->|connects to| Conductor
    Conductor -->|connects to| Proxy

Counterpart Relationship

Each role has exactly one counterpart - who it connects to:

RoleCounterpart
ClientAgent
AgentClient
ProxyConductor
ConductorProxy

This is encoded in the type system: impl ConnectTo<Client> for MyAgent means “MyAgent can connect to a client” (i.e., MyAgent plays the Agent role).

Peer Relationship

Some roles can communicate with multiple peers. The Proxy role is the key example:

graph TB
    subgraph "Proxy's view"
        Proxy
        Client[Client peer]
        Agent[Agent peer]
        Conductor[Conductor counterpart]
    end

    Proxy -.->|"send_to(Client, ...)"| Client
    Proxy -.->|"send_to(Agent, ...)"| Agent
    Proxy -->|"connect_to(conductor)"| Conductor
  • Counterpart (Conductor) - who the proxy connects to (transport layer)
  • Peers (Client, Agent) - who the proxy exchanges logical messages with

Message Flow

Dispatch Loop

Each connection runs a dispatch loop that processes incoming messages:

sequenceDiagram
    participant Transport
    participant DispatchLoop
    participant Handlers
    participant UserCode

    Transport->>DispatchLoop: incoming TransportFrame
    DispatchLoop->>Handlers: try handlers in order

    alt Handler matches
        Handlers->>UserCode: invoke callback
        UserCode-->>Handlers: return result
    else No handler matches
        Handlers->>DispatchLoop: default handler
    end

Handler Chain

Handlers are tried in registration order. The first matching handler wins:

graph TD
    Message[Incoming Message]
    H1[Handler 1: InitializeRequest]
    H2[Handler 2: PromptRequest]
    H3[Handler 3: catch-all]

    Message --> H1
    H1 -->|not InitializeRequest| H2
    H2 -->|not PromptRequest| H3
    H3 --> Done[Handle or error]

    H1 -->|matches| Process1[Process Initialize]
    H2 -->|matches| Process2[Process Prompt]

Ordering Guarantees

The dispatch loop provides sequential processing:

  1. Messages are processed one at a time
  2. A handler runs to completion before the next message is processed
  3. Spawned tasks (connection.spawn()) run concurrently with the dispatch loop

Important: Don’t block the dispatch loop. Use spawn() for long-running work.

Connection Lifecycle

stateDiagram-v2
    [*] --> Building: builder()
    Building --> Building: on_receive_*()
    Building --> Connected: connect_to(transport)
    Building --> Connected: connect_with(transport, closure)
    Connected --> Running: dispatch loop starts
    Running --> [*]: connection closes

Two Connection Modes

Reactive mode (connect_to): The connection runs handlers until the incoming transport reaches clean EOF, drains responses and notifications already accepted by the outgoing queue through the transport sink, then returns Ok(()), including when the builder has long-running with_spawned work. Used for agents and proxies.

Active mode (connect_with): Runs a closure with access to the connection, then closes. Used for clients that drive the interaction. Incoming EOF fails requests that still need responses, but it does not automatically cancel unrelated work in the closure.

Clean Incoming EOF

Incoming EOF is a connection event and a request-liveness boundary:

  • Every pending request is completed with an internal error whose data identifies incoming_transport_closed and the request method; is_incoming_transport_closed() detects it.
  • A request created after EOF fails immediately with the same error.
  • ConnectionTo::incoming_closed() waits for the close event, and is_incoming_closed() reports whether it has completed.
  • Builder::on_close() runs cleanup callbacks in registration order. Returning an error terminates a still-running connect_with foreground; returning Ok(()) leaves its lifetime under application control.

This keeps request correctness separate from async cancellation policy. Applications can finish cleanup or notify a central dispatcher without having an arbitrary foreground future dropped at an await point. Pending requests are failed before close callbacks begin; the close signal is published after callbacks finish, so a callback must not await incoming_closed() itself.

Key Source Files

FilePurpose
src/agent-client-protocol/src/role.rsRole trait and type definitions
src/agent-client-protocol/src/role/acp.rsClient, Agent, Proxy, Conductor roles
src/agent-client-protocol/src/component.rsConnectTo component abstraction
src/agent-client-protocol/src/jsonrpc.rsConnection builder and frame types
src/agent-client-protocol/src/jsonrpc/handlers.rsHandler chain implementation
src/agent-client-protocol/src/jsonrpc/transport_actor.rsLine framing and JSON parsing
src/agent-client-protocol/src/util/typed.rsDispatch typing and matching helpers
src/agent-client-protocol/src/mcp_server/Runtime-agnostic MCP server attachment
src/agent-client-protocol/src/concepts/Rustdoc concept explanations

Design Decisions

Earlier versions used “link types” that encoded both sides (e.g., ClientToAgent). Roles are simpler:

  • One concept instead of two (role vs link)
  • Role types double as peer selectors (send_to(Agent, ...))
  • Clearer mental model: “I am X, connecting to Y”

Why Witness Macros?

The on_receive_request!() macros work around Rust’s lack of return-type notation. They capture the return type of closures at the call site, enabling type inference to work.

Why Not Traits for Handlers?

Handler closures are more ergonomic than trait implementations for most use cases. The HandleDispatchFrom trait exists for advanced cases (reusable handler components).

SDK Protocol Reference

This chapter documents the proxy extension implemented by the Rust SDK’s conductor and the opt-in native MCP-over-ACP transport exposed by the shared ACP schema. The proxy methods are provisional SDK extensions. MCP-over-ACP is also unstable and is available only with the unstable_mcp_over_acp feature.

Method Summary

MethodJSON-RPC shapePurpose
_proxy/initializerequestInitialize a component as a proxy
_proxy/successorrequest or notificationForward one inner ACP message to the next component
mcp/connectrequestOpen a connection to an ACP-provided MCP server
mcp/messagerequest or notificationCarry one inner MCP message over ACP
mcp/disconnectrequestClose an MCP-over-ACP connection

There are no separate request and notification method names for successor or MCP message forwarding. The presence of an outer JSON-RPC id distinguishes a request from a notification.

Proxy Initialization

The conductor sends _proxy/initialize to a component that has a successor. Its parameters are the same fields as the normal InitializeRequest for the selected ACP version. Receiving this method, rather than initialize, tells the component that it is running as a proxy and may forward messages with _proxy/successor.

The response is the matching version’s normal InitializeResponse result. The stable flat schema::InitializeProxyRequest type uses v1; with unstable_protocol_v2, schema::v2::InitializeProxyRequest preserves the v2 request and response types. The final agent receives the ordinary initialize method and does not need to understand the proxy extension.

Successor Forwarding

_proxy/successor wraps one inner ACP method and its parameters. The inner message is flattened into the outer parameters:

{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "_proxy/successor",
  "params": {
    "method": "session/prompt",
    "params": {
      "sessionId": "session-1",
      "prompt": []
    }
  }
}

The conductor unwraps the message and sends the inner request to the next component. The outer response carries the inner request’s result or error. To forward an inner notification, omit the outer id; no response is produced. Optional extension metadata may be included as _meta alongside the flattened inner message.

Native MCP-over-ACP

Enable unstable_mcp_over_acp to use the draft native transport. A component providing an MCP server adds McpServer::Acp to session setup requests (session/new, session/load, session/resume, and the opt-in session/fork). Its wire shape contains a human-readable name and an opaque server identifier:

{
  "type": "acp",
  "name": "project-tools",
  "serverId": "mcp-server:01"
}

serverId identifies the declared server and is used to route mcp/connect back to the component that provided it. A provider must not reuse one server ID for multiple visible servers on the same ACP connection. The high-level agent_client_protocol::mcp_server::McpServer APIs create this declaration automatically.

An agent that consumes this transport advertises agentCapabilities.mcpCapabilities.acp. If the final agent supports HTTP but not ACP-transport MCP servers, place the MCP-over-ACP compatibility bridge immediately before it.

mcp/connect

The MCP client opens a connection to the declared server ID:

{
  "jsonrpc": "2.0",
  "id": 20,
  "method": "mcp/connect",
  "params": { "serverId": "mcp-server:01" }
}

The provider creates one active MCP connection and returns a distinct connection ID:

{
  "jsonrpc": "2.0",
  "id": 20,
  "result": { "connectionId": "mcp-connection:01" }
}

The server ID selects what to connect to; the connection ID selects that particular running connection. All subsequent messages use the connection ID.

mcp/message

mcp/message carries one inner MCP method and its named parameters. The method is bidirectional because MCP clients and servers can both issue requests:

{
  "jsonrpc": "2.0",
  "id": 21,
  "method": "mcp/message",
  "params": {
    "connectionId": "mcp-connection:01",
    "method": "tools/call",
    "params": {
      "name": "example",
      "arguments": {}
    }
  }
}

Use an outer request for an inner MCP request and an outer notification for an inner MCP notification. The outer response carries the inner MCP result or error.

mcp/disconnect

Disconnect is a request so the caller knows that the provider has released the active connection:

{
  "jsonrpc": "2.0",
  "id": 22,
  "method": "mcp/disconnect",
  "params": { "connectionId": "mcp-connection:01" }
}

A successful disconnect returns an empty result:

{
  "jsonrpc": "2.0",
  "id": 22,
  "result": {}
}

Request Cancellation

This chapter documents the $/cancel_request protocol-level notification and how the SDK implements it.

For API usage (cancelling a SentRequest, observing cancellation from a Responder), see the concepts::cancellation chapter in the agent-client-protocol rustdoc.

The $/cancel_request Notification

Either side of a connection may send $/cancel_request to ask the peer to cancel one outstanding JSON-RPC request, identified by its ID:

{
  "jsonrpc": "2.0",
  "method": "$/cancel_request",
  "params": {
    "requestId": "70b9f1c9-c2a3-4bd2-b6b9-65a06d96b675"
  }
}

requestId is the JSON-RPC id of the request to cancel, as allocated by the sender of that request (a string, number, or null).

Semantics

Cancellation is cooperative. After receiving $/cancel_request, the peer may:

  • ignore it and respond to the request normally,
  • finish early with whatever data it has, or
  • respond to the original request with the standard cancellation error, code -32800 (“Request cancelled”).

The requesting side always receives a response to the original request; cancellation only changes which response that is. A $/cancel_request for an unknown or already-completed request ID is silently ignored. A $/cancel_request with malformed params (for example, a requestId that is not a string, number, or null) is logged and ignored without a reply, like any other malformed notification.

Interoperability

Protocol-level ($/-prefixed) notifications are optional by design. The SDK ignores unhandled notifications instead of rejecting them with a method-not-found error. A peer that sends $/cancel_request to a component that does not support cancellation therefore loses nothing: the request simply runs to completion.

Dropping an unconsumed SentRequest asks the peer to cancel it. Use SentRequest::detach() for requests whose eventual response should be ignored, but which should continue running on the peer. The peer is still expected to answer the JSON-RPC request eventually; use a notification instead when no response is expected at all.

Proxy Chains

Cancellation propagates hop by hop rather than end to end. Request IDs are allocated per connection, so a $/cancel_request only ever refers to a request on the connection it is sent over:

  1. The client sends $/cancel_request for a request it made to its direct peer (for example, a proxy).
  2. A proxy that forwarded the request downstream (the SDK does this with forward_response_to) reacts by sending its own $/cancel_request for the downstream request, using the downstream connection’s request ID.
  3. The downstream response — normal data or the cancellation error — flows back up the chain as the response to each hop’s request.

Because the notification is hop-scoped, it is never tunneled across hops: generic forwarding helpers (send_proxied_message_to in the SDK, and the conductor’s internal routing) drop a raw $/cancel_request instead of forwarding a request ID that means nothing on the next connection. The cancellation still reaches the next hop, re-issued by forward_response_to with that hop’s own request ID.

Proxies that intercept methods with custom handlers stay in control: the request’s cancellation marker is their decision point, and handlers see the raw notification before any generic forwarding fallback. A custom handler can handle the cancellation locally, propagate it to a forwarded request (forward_response_to, or forward_cancellation_from when the forwarding needs custom logic), absorb it, or claim the notification and route it itself. See the concepts::cancellation chapter in the agent-client-protocol rustdoc for the full decision matrix.

When the notification targets a request that was wrapped in a _proxy/successor envelope (see the Protocol Reference), the $/cancel_request is wrapped in the same envelope, and requestId refers to the JSON-RPC id of the wrapped request on that connection.

The conductor translates cancellations between hops. Since request IDs are reallocated at every hop, a raw $/cancel_request cannot match anything beyond the hop it was sent over; the conductor re-issues cancellation with the next hop’s request ID instead.

Configurable LLM Providers

The core SDK exposes the draft configurable-provider API through the unstable_llm_providers feature:

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

Draft protocol v2 applications must enable both unstable_protocol_v2 and unstable_llm_providers.

An agent advertises support with AgentCapabilities.providers. After initialization, clients can use three typed client-to-agent requests:

  • providers/list discovers configurable providers, supported API protocols, whether each provider is required, and its current non-secret routing target.
  • providers/set replaces one provider’s API protocol, base URL, and complete header map.
  • providers/disable disables a non-required provider.

Clients should configure providers before creating or loading sessions. Provider configuration is process-scoped and should not be persisted. Disabling an unknown provider is idempotent, while attempts to disable a required provider must be rejected.

The SDK supplies typed wire routing only. Applications remain responsible for capability checks, validating provider IDs and API protocols, enforcing required providers, applying configuration to sessions, and storing header values securely. Since headers may contain credentials, providers/list intentionally returns only apiType and baseUrl and must never echo configured headers.

Sensitive logging: SDK debug and trace instrumentation can include complete JSON-RPC bodies. Treat those logs as sensitive and do not enable body-level logging where provider headers may contain credentials.

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:

#![allow(unused)]
fn main() {
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<_>; 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. 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 completes after the required idle update with stop reason cancelled. The client should immediately mark unfinished tool calls for the active work as cancelled and must resolve every pending permission request with the cancelled outcome. Cancelling or dropping the prompt’s SentRequest is the separate JSON-RPC request-cancellation mechanism.
  • 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.

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; 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.

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 v2 agent requires a v2 client. If a client initializes with v1, the initialize request resolves with an error and the caller must use a v1 agent implementation instead.
  • 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.

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:

#![allow(unused)]
fn main() {
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, selects the highest configured protocol version that is compatible with the requested version, and then hands the connection to that implementation. If only v2 is configured, v1 clients are rejected without changing the fluent API. 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.

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.

Draft schema changes in schema 1.5 through 1.7

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. Programmatic tool-call names are available in both protocol versions through the separate unstable_tool_call_name feature. Draft v2 users must enable both unstable_protocol_v2 and unstable_tool_call_name. In v2, an omitted name leaves the existing value unchanged, null clears it, and a string replaces it. V1 cannot express the explicit v2 null clear operation.

Schema 1.7 stabilizes elicitation and terminal authentication, so neither surface requires its former SDK feature flag. It also adds context compaction updates behind unstable_session_compaction; the SDK carries them through its existing typed session/update routing in both protocol versions. V1 clients advertise compaction support through ClientSessionCapabilities::compaction.

Runnable Protocol V2 Quickstart

The core crate includes a small ACP v2 agent and client that run together over stdio. Both are compiled examples behind the unstable_protocol_v2 feature:

  • simple_agent_v2.rs implements initialization and the complete baseline session lifecycle.
  • v2_one_shot_client.rs initializes the agent, creates a session, sends one prompt, renders text output, waits for the matching session to become idle, and closes it.

Run the pair

Build both examples from the repository root:

cargo build -p agent-client-protocol \
  --features unstable_protocol_v2 \
  --examples

Then point the client at the agent executable:

./target/debug/examples/v2_one_shot_client \
  --command ./target/debug/examples/simple_agent_v2 \
  "Hello from ACP v2"

The result shows the two independent parts of a v2 prompt:

Prompt accepted; waiting for session output and completion...
Echo: Hello from ACP v2
Session is idle: Some(EndTurn)

The agent writes only JSON-RPC to stdout because ACP uses stdout as the wire. Write logs and diagnostics to stderr when extending it.

Client lifecycle

The client installs its session/update and session/request_permission handlers before it opens a session. Permission requests are part of the baseline client surface and have no capability marker; this non-interactive example cancels them explicitly. It then follows this sequence:

  1. Send initialize and verify that the agent advertised session support.
  2. Send session/new and retain the returned command handle and session ID.
  3. Send session/prompt and await its response. This only confirms acceptance.
  4. Ignore queued updates for that new session until its foreground state becomes running. The running update may already be queued when prompt acceptance arrives.
  5. Project subsequent message updates, and treat the next matching state_update with idle as completion of foreground work. An idle update queued before running is only the session’s earlier ready state. Background updates may still arrive afterward.
  6. Send session/close when the client no longer needs the active session.

Real clients normally maintain one shared update projection for every session. Do not install a temporary handler after sending a prompt: updates can arrive before the prompt response and are not scoped to a prompt or turn ID. Within that projection, message chunks append by messageId; a later message snapshot with concrete content replaces the accumulated chunks, null clears them, and omitted content preserves them. Rendering chunks and then rendering a snapshot again would duplicate output.

Agent lifecycle

Advertising AgentCapabilities::session(SessionCapabilities::new()) commits an agent to the baseline session surface. The example handles:

  • session/new
  • session/list
  • session/resume
  • session/close
  • session/prompt
  • session/cancel
  • session/update notifications sent to the client

The prompt handler validates and marks the session busy, responds to session/prompt immediately, and moves the actual work into a spawned task so the connection can continue dispatching cancellation and other traffic. That task sends the accepted user message, a running update, output, and finally an idle update with a stop reason.

The example keeps history in memory and supports replay from the start before the session/resume response. A production agent should replace this with durable session storage, define its supported replay cursors, and make resource cleanup and cancellation robust across process failure.

For the connection APIs, proxy routing, and compatibility details surrounding these examples, continue with Protocol V2.

Transport Architecture

For the broader user-facing API, see the Core Library Design and the agent-client-protocol rustdoc.

This chapter explains how the connection layer separates protocol semantics from transport mechanisms, enabling flexible deployment patterns including in-process message passing.

Overview

The SDK’s connection core provides the JSON-RPC abstraction used by ACP components. It supports pluggable transports that work with different I/O mechanisms while maintaining consistent protocol semantics.

Design Principles

Separation of Concerns

The architecture separates two distinct responsibilities:

  1. Protocol Layer: JSON-RPC semantics

    • Request ID assignment
    • Request/response correlation
    • Method dispatch to handlers
    • Error handling
  2. Transport and framing layer: Message movement and JSON-RPC envelope validation

    • Reading/writing from I/O sources
    • Serialization/deserialization
    • Preserving single-value and batch boundaries
    • Connection management

This separation enables:

  • In-process efficiency: Components in the same process can skip serialization
  • Transport flexibility: Easy to add new transport types (WebSockets, named pipes, etc.)
  • Testability: Mock transports for unit testing
  • Clarity: Clear boundaries between protocol and I/O concerns

The TransportFrame Boundary

The public, transport-neutral boundary is TransportFrame. A frame contains one RawJsonRpcMessage, one structurally non-empty TransportBatch, or a malformed wire value that a relay must preserve. RawJsonRpcMessage is backed by the JSON-RPC envelope types from agent-client-protocol-schema:

#![allow(unused)]
fn main() {
enum RawJsonRpcMessage {
    Request(Request<RawJsonRpcParams>),
    Notification(Notification<RawJsonRpcParams>),
    Response(Response<serde_json::Value>),
}
}

At that boundary:

  • Above: Protocol layer works with application types (OutgoingMessage, UntypedMessage)
  • Below: Transport actors parse and serialize JSON-RPC frames
  • Boundary: TransportFrame carries one raw message, a structurally non-empty batch, or a malformed wire value retained for a relay
  • In-process API: Channel::rx and Channel::tx carry TransportFrame directly, so adapters cannot accidentally flatten a batch
  • Failures: I/O and connection failures are returned by the future driving a transport; they are not sent as channel entries

TransportFrame::parse_json returns one frame for every input string, including malformed response-shaped input. Standalone malformed input retains its exact text. Batch entries retain their parsed JSON values, source order, and batch boundary, although serializing a relayed batch may normalize whitespace. The protocol actor, not the parser, decides whether malformed input requires a response.

Actor Architecture

Protocol Actors

These actors live in the protocol connection core and understand JSON-RPC semantics:

Outgoing Protocol Actor

Input:  mpsc::UnboundedReceiver<OutgoingMessage>
Output: mpsc::UnboundedSender<TransportFrame>

Responsibilities:

  • Assign unique IDs to outgoing requests
  • Register pending replies before sending requests
  • Convert application-level OutgoingMessage to protocol-level RawJsonRpcMessage

Incoming Protocol Actor

Input:  mpsc::UnboundedReceiver<TransportFrame>
Output: Routes to pending request awaiters or registered handlers

Responsibilities:

  • Route responses to pending request awaiters (matched by ID)
  • Route requests/notifications to registered handlers
  • Convert schema request/notification envelopes to UntypedMessage for handlers
  • Retain batch response slots while entries are dispatched
  • Emit one response array only after every response-bearing entry has completed and all entries in the batch have been dispatched
  • Emit nothing for a notification-only batch; answer an empty batch with one standalone Invalid Request response

Pending Reply Registry

The shared pending-reply registry manages request/response correlation:

  • Maintains map from request ID to response channel
  • When response arrives, delivers to waiting request

Task Actor

Runs user-spawned concurrent tasks via cx.spawn().

Transport Actors

These actors are driven by physical transport components. They understand JSON-RPC framing and envelope validity, but they do not dispatch ACP methods or correlate responses with pending requests:

Transport Outgoing Actor

Input:  mpsc::UnboundedReceiver<TransportFrame>
Output: Writes to I/O (byte stream, channel, socket, etc.)

For byte streams:

  • Serialize a single RawJsonRpcMessage or one non-empty batch to JSON
  • Write newline-delimited JSON to stream

For in-process channels:

  • Directly forward TransportFrame to the channel

Transport Incoming Actor

Input:  Reads from I/O (byte stream, channel, socket, etc.)
Output: mpsc::UnboundedSender<TransportFrame>

For byte streams:

  • Read newline-delimited JSON from stream
  • Parse a single message or a non-empty batch array
  • Retain the batch boundary while dispatching each RawJsonRpcMessage entry to the incoming protocol actor
  • Retain malformed entries so relays can forward the complete frame
  • Leave call/response-shape classification and Error Response decisions to the incoming protocol actor

For in-process channels:

  • Directly forward TransportFrame from the channel

The public Channel boundary preserves complete frames. The SDK continues to initiate requests and notifications as individual JSON-RPC messages; response arrays are correlated replies to batch calls received from the peer. Relays and instrumentation must forward frames intact so they do not change those wire semantics.

JSON-RPC Batch Behavior

Batch support is shared by the stable v1 and draft v2 APIs because it belongs to the JSON-RPC transport layer:

  • Lines, ByteStreams, Stdio, and the HTTP/WebSocket adapters accept incoming JSON-RPC arrays.
  • Entries are validated and dispatched independently and in source order. An invalid call-shaped entry receives its own Invalid Request error without preventing valid siblings from running.
  • Responses for response-bearing entries are collected and written as one response array after dispatch completes. A notification-only batch receives no response.
  • If a handler drops a batched request’s Responder, the completed dispatch supplies an Internal Error for that slot so completed siblings are not stranded. Returning a handler error supplies that error instead. Dropping an individual request’s responder continues to send no automatic response.
  • An empty input array receives one standalone Invalid Request response; the SDK never writes an empty response array.
  • Response entries are routed by request ID. The framing layer retains malformed values, but the protocol actor ignores values that are response-shaped and not call-shaped because a JSON-RPC response must not itself receive a response; ambiguous call-shaped values still receive Invalid Request.
  • The SDK does not originate batches of requests or notifications. Individual calls continue to receive individual responses; a response array is emitted only for an incoming call batch.

Relays, wrappers, and tracing bridges must forward the complete TransportFrame. Flattening a batch changes observable JSON-RPC semantics even when every individual message remains valid.

Lifecycle-sensitive calls should normally be sent individually. As a compatibility measure, AgentProtocolRouter can select a v1 or v2 agent when the first call-shaped entry is initialize, while preserving the original frame for the selected implementation. Response-only frames received before initialization are ignored. ProxyProtocolRouter provides the analogous boundary after conductor selection: it requires an exact v1 or v2 _proxy/initialize match and preserves the complete initial frame without cross-version conversion. ClientProtocolConnector starts each attempted client implementation with an individual initialize request.

Message Flow

Outgoing Message Flow

User Handler
    |
    | OutgoingMessage (request/notification/response)
    v
Outgoing Protocol Actor
    | - Assign ID (for requests)
    | - Subscribe to replies
    | - Convert to RawJsonRpcMessage
    v
    | TransportFrame (single message or batch response)
    |
Transport Outgoing Actor
    | - Serialize (byte streams)
    | - Or forward directly (channels)
    v
I/O Destination

Incoming Message Flow

I/O Source
    |
Transport Incoming Actor
    | - Parse (byte streams)
    | - Or forward directly (channels)
    v
    | TransportFrame (single message or incoming batch)
    |
Incoming Protocol Actor
    | - Route responses → pending request awaiters
    | - Route requests → registered handlers
    v
Handler or request awaiter

Message Ordering in the Conductor

The conductor’s central routing loop serializes forwarding decisions for incoming requests and notifications. Responses stay paired with the request contexts managed by the protocol layer and may take a direct response path. The conductor therefore does not promise a global total order across unrelated concurrent requests, but every underlying transport sink preserves the order in which complete frames are accepted. See Conductor Routing and Ordering.

Component Boundary

ConnectTo is the common component and transport abstraction. connect_to joins a component to its counterpart and drives the connection until completion. into_channel_and_future exposes the canonical low-level boundary as a Channel plus the future that drives the component:

fn into_channel_and_future(self) -> (Channel, BoxFuture<'static, Result<()>>);

The returned future owns transport failures and lifecycle completion. The channel carries only TransportFrame wire events. Most components implement only connect_to; direct transports override into_channel_and_future to avoid an intermediate copy.

Transport Implementations

Byte Stream Transport

ByteStreams<Outgoing, Incoming> works with futures::io::AsyncWrite and AsyncRead. It adapts them to Lines, parses each incoming JSON value into one frame, and serializes each outgoing frame to one newline-delimited JSON value. Runtime adapters with different I/O traits can instead bridge asynchronous lines into Lines.

The native Stdio and AcpAgent transports build on ByteStreams. Their current implementations depend on process spawning and blocking-thread facilities, so they are not exported on wasm32-wasip1 or wasm32-wasip2. The runtime-neutral protocol engine and transport abstractions compile for both targets, but this crate does not provide a WASI executor or host I/O adapter.

Use cases:

  • Stdio connections to subprocess agents
  • TCP socket connections
  • Unix domain sockets
  • Any stream-based I/O

In-Process Channel

For components in the same process, Channel::duplex() creates paired endpoints and skips serialization entirely. Relays forward each received TransportFrame without unpacking it; this preserves batch boundaries and the original representation of malformed wire input.

Benefits:

  • Zero serialization overhead: Messages passed by value
  • Same-process efficiency: Ideal for conductor with in-process proxies
  • Explicit wire state: No serialize/parse round trip is required, while a malformed value received from a physical transport remains an explicit frame

Typed Component Interface

The community yosh:acp@8.0.1 package defines an independently versioned typed WIT projection of ACP for the WASI 0.3 async component model. In that design, the host processes the ACP JSON-RPC connection outside the component and maps it to typed calls across the component boundary. The package provides interface definitions, not a transport or integration supplied by this SDK.

Use Cases

1. Standard Agent (Stdio)

Use native Stdio for the current process or AcpAgent for a child process. Both use the same frame-aware line transport underneath.

2. In-Process Proxy Chain

Connect builders, proxies, and conductor components directly. Their default ConnectTo adapter uses Channel, so complete frames cross each wrapper with no serialization.

3. Network-Based Components

Split the socket and pass compatible read/write halves to ByteStreams::new.

4. WASI Embedding

Embedders supply and drive their own runtime and host transport:

  • Exchange TransportFrame values through an in-component Channel. A caller using ConnectTo::into_channel_and_future must poll the returned future.
  • Exchange newline-delimited JSON through Lines, using a futures::Sink<String> and futures::Stream<Item = std::io::Result<String>>.
  • Use ByteStreams with futures::io::AsyncRead and AsyncWrite.

5. Testing with Mock Transport

Use Channel::duplex() to inject and inspect TransportFrame values without real I/O.

Benefits

Performance

  • In-process optimization: Skip serialization when components are co-located
  • Zero-copy potential: Direct message passing for channels
  • Flexible trade-offs: Choose appropriate transport for deployment

Flexibility

  • Transport-agnostic handlers: Write handler logic once, use anywhere
  • Easy experimentation: Try different transports without code changes
  • Future-proof: Add new transports (WebSockets, gRPC, etc.) without refactoring

Testing

  • Mock transports: Unit test handlers without I/O
  • Deterministic tests: Control message timing precisely
  • Isolated testing: Test protocol logic separate from I/O

Clarity

  • Clear boundaries: Protocol semantics vs transport mechanics
  • Focused implementations: Each layer has single responsibility
  • Maintainability: Changes to transport don’t affect protocol logic

HTTP / WebSocket Transport

agent-client-protocol-http exposes ACP agents over one /acp endpoint.

  • POST /acp with initialize creates a connection and returns Acp-Connection-Id.
  • Later POST /acp requests include Acp-Connection-Id; session-scoped requests also include Acp-Session-Id or params.sessionId.
  • GET /acp with Accept: text/event-stream streams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages.
  • GET /acp with a WebSocket upgrade uses text frames for JSON-RPC messages.
  • DELETE /acp tears down the connection.

POST /acp request bodies are limited to 16 MiB.

JSON-RPC Batches

HttpClient starts every connection with an individual initialize and requires an individual initialize response. For compatibility with other clients, the server also accepts an initial batch when its first call-shaped entry is an initialize request. Valid and malformed response-only entries may precede it and are ignored; an invalid or call-shaped predecessor rejects the batch as an initial frame. The server forwards the complete frame and returns the complete grouped response in the POST response body; a successful initialize also adds Acp-Connection-Id. Lifecycle-sensitive calls should normally remain individual. If the agent emits a notification or callback before the initialize response is ready, including from a batched sibling, the server buffers that frame for the connection’s SSE stream until initialization completes.

After initialization, both transport shapes preserve batches:

  • On an established HTTP connection, one complete batch occupies one POST body. The server returns 202 Accepted; any grouped JSON-RPC reply is delivered through SSE as one array.
  • WebSocket sends one complete batch in one text frame and writes its grouped reply in one text frame.
  • A grouped HTTP reply is sent to a session stream only when all correlated entries have the same session route. If routes differ, it is sent on the connection-level stream so the array remains intact.

Entry validation, notification-only behavior, empty arrays, and malformed response filtering follow the shared transport batch contract.

HTTP + SSE Streams

After initialize, clients should open a connection-level SSE stream:

  • GET /acp
  • Accept: text/event-stream
  • Acp-Connection-Id: <connection id>
  • no Acp-Session-Id

This stream carries connection-scoped messages.

Session-scoped messages are routed to session-specific SSE streams. For each active session, clients should also open:

  • GET /acp
  • Accept: text/event-stream
  • Acp-Connection-Id: <connection id>
  • Acp-Session-Id: <session id>

Open a session stream before sending methods such as session/prompt, session/load, session/resume, or other session-scoped requests. When a session/new or session/fork response returns a new sessionId, open an SSE stream for that returned session before expecting updates or responses for it.

Features

The crate does not enable either transport side by default. Opt into only the side(s) you need.

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

The client feature exposes HttpClient. The server feature exposes AcpHttpServer, ServerOptions, and CorsOptions.

Request Cancellation

Request cancellation is available through the core SDK:

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

$/cancel_request is connection-scoped. The HTTP transport does not apply Acp-Session-Id to cancellation notifications, and routes outgoing cancellation notifications over the connection stream rather than a session stream.

WebSocket connections can carry cancellation at any point after the socket is open. With HTTP + SSE, cancellation can be sent after initialize completes and the client has received Acp-Connection-Id; an in-flight initialize request cannot be cancelled with a hop-local $/cancel_request on this transport shape.

Server

#![allow(unused)]
fn main() {
use agent_client_protocol_http::AcpHttpServer;

let app = AcpHttpServer::new(|| my_agent()).into_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app).await?;
}

Cross-origin browser access is disabled by default. Enable it by allowlisting the browser origins that should be able to access the ACP endpoint:

#![allow(unused)]
fn main() {
use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions};

let app = AcpHttpServer::new(|| my_agent())
    .with_options(ServerOptions {
        cors: CorsOptions::allow_origins(["http://localhost:5173"])?,
        ..ServerOptions::default()
    })
    .into_router();
}

Client

#![allow(unused)]
fn main() {
use agent_client_protocol_http::HttpClient;

let transport = HttpClient::new("http://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;
}

The same HttpClient also speaks WebSocket — pass a ws:// or wss:// URL and it will open a single bidirectional connection instead of using POST + SSE:

#![allow(unused)]
fn main() {
let transport = HttpClient::new("ws://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;
}

Conductor Design

The agent-client-protocol-conductor crate runs a chain of ACP proxy components. It presents one ACP endpoint upstream while owning the connections to every proxy and, in agent mode, the final agent.

For API details, see the agent-client-protocol-conductor rustdoc.

Chain Model

flowchart LR
    Client[ACP client]
    Conductor[Conductor]
    Proxy1[Proxy 1]
    Proxy2[Proxy 2]
    Agent[Agent]

    Client <--> Conductor
    Conductor <--> Proxy1
    Conductor <--> Proxy2
    Conductor <--> Agent

Components do not open direct connections to one another. The conductor owns each transport and maps the logical chain onto those connections:

  • Upstream traffic is delivered to the first proxy as ordinary ACP messages.
  • A proxy uses _proxy/successor to send a request or notification to the next component.
  • Traffic from a successor is presented to its predecessor through the same typed proxy abstraction.
  • JSON-RPC responses remain paired with the request context that caused them.

The final agent receives ordinary ACP and does not need to implement the proxy extension. See the Proxy Extension Protocol Reference for the wire method shapes.

Lazy Initialization

Proxy and agent components are instantiated when the first initialize request arrives. For each non-final component, the conductor sends _proxy/initialize; the last component in agent mode receives ordinary initialize. Each proxy can initialize its successor before completing its own response, so capabilities flow back toward the client through the chain.

Lazy construction allows an InstantiateProxiesAndAgent or InstantiateProxies implementation to inspect and, when appropriate, adjust the initialize request before choosing components.

With the conductor crate’s unstable_protocol_v2 feature, initialization selects the v1 or v2 schema from the raw protocolVersion before deserialization. This prevents v2 info, capabilities, metadata, and future extension fields from being interpreted as a permissive v1 request and dropped. An exact-version request whose typed value is unchanged keeps its original raw parameters, including unknown extensions. A request for a later compatible protocol version selects v2 and is canonicalized through the selected v2 schema. The command-line component provider, AgentOnly, ProxiesAndAgent, and static proxy vectors 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 instantiators can implement the feature-gated instantiate_v2_proxies_and_agent or instantiate_v2_proxies method; their default implementation rejects v2 with a JSON-RPC response and leaves the connection in a failed state that rejects later traffic. A modified typed request is serialized as the new authoritative payload, while its protocolVersion remains pinned to the implementation the conductor selected.

Agent and Proxy Modes

In agent mode, the conductor owns zero or more proxies followed by a final agent and acts as an agent toward its upstream client.

In proxy mode, the conductor owns only a proxy sub-chain. The final managed proxy’s successor is the conductor’s own downstream successor, allowing a sub-chain to participate as one proxy inside a larger composition.

Routing and Ordering

A central routing loop serializes forwarding decisions for incoming requests and notifications. Responses are associated with their original requests by the core JSON-RPC contexts and may use a direct response path; the conductor does not maintain a second global request-ID table.

Every physical and in-process bridge carries TransportFrame, so tracing and delegating components preserve JSON-RPC batch boundaries. This matters because flattening a batch would change one response array into several response objects. The complete framing rules are documented in Transport Architecture.

Command-Line Usage

Global options precede the subcommand. Each component argument is one shell-parsed command string, so quote commands that include arguments:

agent-client-protocol-conductor agent \
  "proxy-one --flag" \
  "proxy-two" \
  "base-agent --acp"

The last command in agent mode is the agent; earlier commands are proxies. Proxy mode accepts only proxy commands:

agent-client-protocol-conductor proxy "proxy-one" "proxy-two"

Tracing options are global:

agent-client-protocol-conductor --trace ./trace.jsons agent "proxy-one" "base-agent"
agent-client-protocol-conductor --serve agent "proxy-one" "base-agent"
agent-client-protocol-conductor --trace ./trace.jsons --serve agent "proxy-one" "base-agent"

Build the opt-in binary with draft-v2 proxy initialization enabled using:

cargo build -p agent-client-protocol-conductor --features unstable_protocol_v2

There is no conductor mcp subcommand. Compatibility for HTTP-capable agents that lack the native ACP MCP transport lives in agent-client-protocol-polyfill and must be inserted explicitly when needed.

Programmatic Usage

use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};

let components = ProxiesAndAgent::new(agent)
    .proxy(first_proxy)
    .proxy(second_proxy);

ConductorImpl::new_agent("conductor", components)
    .run(upstream_transport)
    .await?;

ConductorImpl::new_proxy accepts an InstantiateProxies implementation for the nested-proxy case. Both modes can use dynamic instantiator closures when the chain depends on v1 initialization data. A custom instantiator type can implement both initialization methods when dynamic selection is also needed for v2.

MCP Compatibility

MCP-over-ACP adaptation is intentionally not built into ConductorImpl. Add McpOverAcpPolyfill::http() as a proxy in the chain immediately before a final agent that cannot consume native McpServer::Acp declarations. The provider-facing side continues to use the feature-gated mcp/connect, mcp/message, and mcp/disconnect methods; only the final-agent side is adapted to HTTP. Keeping the polyfill explicit prevents instrumentation or orchestration from silently changing session MCP declarations. See MCP Bridge.

The polyfill supports v1 by default. For a draft-v2 chain, enable unstable_protocol_v2 on both the conductor and polyfill crates; without the polyfill feature, it rejects v2 initialization instead of interpreting v2 traffic as v1.

Tracing

The conductor can record an idealized logical sequence of ACP and MCP messages. Its snooping bridges retain complete transport frames, so enabling tracing does not change batch behavior. See Trace Viewer for the event format and current CLI/API examples.

MCP-over-ACP Compatibility Bridge

agent-client-protocol-polyfill::mcp_over_acp::McpOverAcpPolyfill adapts the native ACP MCP transport for a final agent that accepts HTTP MCP servers. MCP adaptation is explicit and is not built into the conductor.

The component-facing side of the bridge always uses the opt-in native protocol:

  • Servers are declared as McpServer::Acp with a serverId.
  • Connections use mcp/connect, mcp/message, and mcp/disconnect.
  • mcp/disconnect is a request with a response.

The SDK-local underscore-prefixed method family and HTTP declarations with a special URL scheme have been retired. The polyfill now translates native declarations to real localhost HTTP URLs only at the compatibility boundary.

Native MCP-over-ACP requires the core SDK’s unstable_mcp_over_acp feature. The polyfill enables that feature on its core dependency, so applications using the polyfill receive it through Cargo feature unification.

The polyfill supports stable protocol v1 by default. To place it in a draft-v2 conductor chain, enable unstable_protocol_v2 on both the conductor and polyfill dependencies:

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

The feature makes this concrete compatibility proxy recognize v2 initialization, capability, session setup, and mcp/* wire types. It does not change the core attachment API. Proxy.v2().with_mcp_server(...) provides connection-global attachment. V2SessionBuilder::with_mcp_server(...) and V2ResumeSessionBuilder::with_mcp_server(...) provide per-session attachment for new and resumed sessions respectively. With unstable_session_fork, V2ForkSessionBuilder::with_mcp_server(...) provides per-session fork attachment. The polyfill adapts their native declarations when the final agent supports only HTTP MCP.

Placement

Insert the polyfill immediately before the final agent that lacks native MCP-over-ACP support:

use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};
use agent_client_protocol_polyfill::mcp_over_acp::McpOverAcpPolyfill;

let components = ProxiesAndAgent::new(agent)
    .proxy(application_proxy)
    .proxy(McpOverAcpPolyfill::http());

ConductorImpl::new_agent("conductor", components)
    .run(upstream_transport)
    .await?;

The application proxy can attach a high-level agent_client_protocol::mcp_server::McpServer. The SDK advertises it in session setup requests as McpServer::Acp; callers do not need to construct a transport placeholder themselves. In v2, Proxy.v2().with_mcp_server(...) provides connection-global attachment. V2SessionBuilder::with_mcp_server(...) and V2ResumeSessionBuilder::with_mcp_server(...) provide per-session attachment for new and resumed sessions respectively. With unstable_session_fork, V2ForkSessionBuilder::with_mcp_server(...) provides per-session fork attachment. The polyfill translates those native declarations at the final compatibility boundary.

During initialization, the polyfill forwards the request to its successor. When the successor advertises HTTP MCP support, the polyfill advertises native ACP MCP support in the response seen upstream:

  • v1 sets agentCapabilities.mcpCapabilities.acp to true.
  • v2 adds the capabilities.session.mcp.acp marker.

In this chain position that capability means the chain can consume native MCP-over-ACP declarations through the adapter; it does not imply that the final agent implements the transport itself.

If the successor already advertises native ACP MCP support, the polyfill leaves the capability, declarations, and mcp/message traffic unchanged. If it supports neither native nor HTTP MCP, the polyfill does not advertise ACP MCP support and rejects any native declaration that is nevertheless supplied.

Transformation

For each schema-selected McpServer::Acp entry in a session setup request, the polyfill:

  1. Creates or reuses a connection-scoped localhost bridge endpoint for the serverId and replaces the declaration with the HTTP transport for the final agent.
  2. Retains the native serverId so connections can be routed back to the component that provided the server.
  3. Opens the endpoint’s native connection by sending mcp/connect with that server ID toward the provider.
  4. Relays requests and notifications through mcp/message, using the returned connectionId for that active MCP connection.
  5. Sends an mcp/disconnect request when the local transport closes and removes the connection from the bridge.

Enable the polyfill crate’s unstable_session_fork feature when adapting fork requests. Stable v1 setup includes session/new, session/load, and session/resume; draft v2 includes session/new and session/resume. Both versions include session/fork when unstable_session_fork is enabled.

Declarations using another transport are left unchanged, including extension transports represented by v2’s McpServer::Other.

Endpoints are cached by serverId across session setup requests on the ACP connection. The output declaration is rebuilt for each occurrence, preserving that occurrence’s name, _meta, and other unmodified extension fields even when its endpoint is reused.

The native wire envelopes are documented in the SDK Protocol Reference.

HTTP Mode

McpOverAcpPolyfill::http() is the default compatibility shape. It replaces the native declaration with an HTTP MCP URL at http://127.0.0.1:PORT. The embedded server accepts MCP POST requests and an SSE GET stream at /, retaining JSON-RPC batch frames and correlating each POST with its response.

let bridge = McpOverAcpPolyfill::http();

The listener is bound only on loopback and uses an ephemeral port. It does not implement resumable SSE event IDs.

Lifecycle and Failure Behavior

Each bridge endpoint receives a unique connectionId from mcp/connect. The polyfill keeps a connection map until the endpoint’s transport task closes, then removes the entry, sends mcp/disconnect, and observes its response. Request failures use the corresponding request’s error path; notifications are never answered with synthetic errors.

A reverse mcp/message request for an unknown connectionId receives Invalid params. A reverse notification for an unknown connection is ignored, as required for JSON-RPC notifications.

The polyfill does not infer or store ACP session IDs. Association is carried by the declared serverId and the resulting active connectionId.

Testy ACP Test Agent

testy is a deterministic ACP agent binary for exercising clients against ACP. It is built from the agent-client-protocol-test crate and communicates over stdio like a normal agent. Its v1 and draft v2 implementations are native rather than protocol conversions.

The default build enables agent-client-protocol-test’s unstable cargo feature, which forwards to the SDK’s unstable feature and builds the v1 agent:

cargo build -p agent-client-protocol-test --bin testy

Enable the separate draft v2 feature to build a dual-version binary:

cargo build -p agent-client-protocol-test --bin testy --features unstable_protocol_v2

That binary selects v1 or v2 from the client’s initialize request. just prep-tests already builds Testy with all features, so the prebuilt test binary supports both versions.

To build stable-only coverage:

cargo build -p agent-client-protocol-test --bin testy --no-default-features

The binary lands at target/debug/testy. Integration tests that need to spawn it should use agent_client_protocol_test::test_binaries::testy() after prebuilding test binaries.

Prompt Commands

Prompt text can be either plain text or a JSON-serialized TestyCommand.

Plain-text commands:

  • help returns the supported commands and scenarios.
  • echo <message> streams <message> back.
  • wait_for_cancel accepts the prompt and waits for session/cancel.
  • session_updates emits every stable session/update variant.
  • content emits prompt/content-focused updates, including every stable ContentBlock variant.
  • tool_calls emits tool call create and update flows.
  • callbacks sends every stable agent-to-client request.
  • elicitations sends only elicitation requests.
  • cancel_status reports whether session/cancel has been received.
  • full runs all stable scenarios in deterministic order.

callbacks and full also run the elicitation coverage.

JSON command form:

{"command":"run_scenario","scenario":"elicitations"}

Coverage

Protocol v1

The binary handles every stable client-to-agent v1 request and notification: initialize, authenticate, logout, session/new, session/load, session/list, session/delete, session/resume, session/close, session/set_mode, session/set_config_option, session/prompt, and session/cancel.

The full scenario sends every stable agent-to-client callback request: session/request_permission, fs/write_text_file, fs/read_text_file, terminal/create, terminal/output, terminal/wait_for_exit, terminal/kill, and terminal/release. It also emits the stable session update variants, including message chunks, tool calls, plans, available commands, mode/config/session info, and usage.

elicitations, callbacks, and full cover elicitation/create form mode, URL mode, session scope, request scope, accept, decline, cancel, and elicitation/complete. If the client does not advertise form elicitation, the scenario returns a deterministic invalid-params prompt error before sending an elicitation request. If the client advertises form elicitation but not URL elicitation, the URL part returns a deterministic invalid-params prompt error.

Draft protocol v2

With unstable_protocol_v2, the binary also handles the complete advertised v2 session baseline: initialize, session/new, session/list, session/resume, session/close, session/prompt, session/cancel, and session/update.

The v2 implementation follows the split prompt lifecycle:

  1. session/prompt returns an empty acceptance response.
  2. Testy independently sends the accepted user message and a running state update.
  3. Output arrives through message updates.
  4. An idle state update with a stop reason completes the foreground work.

wait_for_cancel makes this separation deterministic for client tests: prompt acceptance returns while work remains active, and session/cancel is confirmed by idle with the cancelled stop reason. Testy also keeps simple message history and replays it before a session/resume response when the client requests replay from the start.

The existing v1 scenarios do not map one-to-one onto v2. V2 scenario parity, client callbacks, MCP, authentication, deletion, configuration, and other optional capabilities remain unadvertised for now.

Trace Viewer

agent-client-protocol-trace-viewer renders conductor message traces as an interactive sequence diagram. Capture lives in agent-client-protocol-conductor; the viewer can read the resulting file while the conductor is running or after it exits.

Capture and View

Conductor options are global and therefore precede the subcommand:

agent-client-protocol-conductor --trace ./trace.jsons agent \
  "proxy-one" "base-agent"

agent-client-protocol-trace-viewer ./trace.jsons

The standalone viewer chooses a loopback port and opens a browser. Use --port PORT to choose the port or --no-open to suppress browser launch.

The conductor can also host the viewer directly:

# In-memory live trace
agent-client-protocol-conductor --serve agent "proxy-one" "base-agent"

# File-backed live trace
agent-client-protocol-conductor --trace ./trace.jsons --serve agent \
  "proxy-one" "base-agent"

Starting file capture truncates an existing trace file. Each event is flushed as one JSON object followed by a newline.

Event Schema

The .jsons file contains exactly three event variants: request, response, and notification. It does not capture stderr or general tracing log records.

type TraceEvent = RequestEvent | ResponseEvent | NotificationEvent;

interface RequestEvent {
  type: "request";
  ts: number;
  protocol: "acp" | "mcp";
  from: string;
  to: string;
  id: unknown;
  method: string;
  session?: string;
  params: unknown;
}

interface ResponseEvent {
  type: "response";
  ts: number;
  from: string;
  to: string;
  id: unknown;
  is_error: boolean;
  payload: unknown;
}

interface NotificationEvent {
  type: "notification";
  ts: number;
  protocol: "acp" | "mcp";
  from: string;
  to: string;
  method: string;
  session?: string;
  params: unknown;
}

ts is monotonic seconds since capture began. JSON-RPC IDs may be strings, integers, or null; consumers must not assume they are numbers. The optional session field is omitted when the tracer has no session context; it is not serialized as null.

Components currently use the conductor’s debug names, such as Client, Proxy(0), and Agent.

Idealized Message Flow

The trace shows logical component-to-component traffic rather than conductor plumbing:

  • _proxy/successor is unwrapped and logged as its inner ACP method.
  • mcp/message is unwrapped and its inner method is marked with protocol mcp.
  • Responses are correlated with the request details retained by the trace writer.

The snooping bridges carry complete TransportFrame values. Enabling tracing therefore preserves batch boundaries even though the viewer renders the individual logical messages.

Viewer Features

The web UI provides ACP/MCP/response filters, request-response color pairing, active-request spans, elapsed-time labels, session/update text previews, a resizable JSON detail panel, and adjustable swimlane width. The file-backed server rereads the trace on each poll so new events appear during capture.

Programmatic Capture

use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};

let conductor = ConductorImpl::new_agent(
    "conductor",
    ProxiesAndAgent::new(agent).proxy(proxy),
)
.trace_to_path("./trace.jsons")?;

conductor.run(upstream_transport).await?;

Use trace_to with a custom implementation of agent_client_protocol_conductor::trace::WriteEvent to send events somewhere other than a file. with_trace_writer accepts an already configured TraceWriter.

The viewer crate also exposes serve_file and serve_memory. The memory-backed form returns a TraceHandle for pushing JSON events and a server future that the caller must drive.

Migrating from agent-client-protocol 1.x to 2.0

Version 2.0 makes JSON-RPC notification semantics explicit, changes the low-level in-process transport boundary so frames remain intact across components and adapters, clarifies the distinction between responding to requests and routing responses, makes dynamic handler lifetimes explicit, and gives AcpAgent an SDK-owned process-launch configuration instead of reusing an MCP wire-schema type. It also replaces the SDK-local MCP-over-ACP wire extension with the shared schema’s opt-in native transport.

Coordinated crate versions

Most published workspace crates move to the 2.x version family together. Crates whose public APIs expose core SDK types must use the matching major release; the trace viewer and cookbook are version-aligned as part of the coordinated release but do not impose that public dependency constraint. The rmcp integration moves to 3.x because both agent-client-protocol and rmcp are public dependencies in its API:

CrateCoordinated release
agent-client-protocol2.x
agent-client-protocol-derive2.x
agent-client-protocol-conductor2.x
agent-client-protocol-cookbook2.x
agent-client-protocol-http2.x
agent-client-protocol-polyfill2.x
agent-client-protocol-trace-viewer2.x
agent-client-protocol-rmcp3.x

Notifications cannot receive error responses

The SDK no longer exposes ConnectionTo::send_error_notification, and Dispatch::respond_with_error has been removed. Match the dispatch variant when a catch-all handler needs different behavior:

#![allow(unused)]
fn main() {
use agent_client_protocol::{Dispatch, Error};

fn handle(message: Dispatch) -> Result<(), Error> {
match message {
    Dispatch::Request(_, responder) => {
        responder.respond_with_error(Error::method_not_found())
    }
    Dispatch::Notification(_) => Ok(()),
    Dispatch::Response(result, router) => router.route_with_result(result),
}
}
}

In most applications, omit the catch-all handler entirely. The built-in fallback responds to unknown requests with Method not found, ignores unhandled notifications, and routes responses to their pending requests.

TypeNotification no longer has a role parameter or takes a connection:

#![allow(unused)]
fn main() {
use agent_client_protocol::util::TypeNotification;

// 1.x
// TypeNotification::<Peer>::new(message, &connection)

// 2.0
TypeNotification::new(message)
;
}

The notification type parameter of Dispatch<Req, Notif> now requires Notif: JsonRpcNotification, matching the variant it can contain. The duplicate Dispatch::erase_to_json method was removed; use into_untyped_dispatch.

Raw channels carry frames

Channel is now the single batch-aware in-process transport boundary. Its rx and tx carry TransportFrame, not Result<RawJsonRpcMessage, Error>:

#![allow(unused)]
fn main() {
use agent_client_protocol::{Channel, RawJsonRpcMessage, TransportFrame};

fn send(channel: &Channel, message: RawJsonRpcMessage) {
channel.tx.unbounded_send(TransportFrame::Single(message)).unwrap();
}
}

TransportFrame distinguishes a valid single message, a non-empty TransportBatch, and an explicit malformed wire value. Each batch contains public TransportBatchEntry values so a relay can retain invalid siblings without turning a protocol error into a transport failure. All three public frame types implement Clone, and TransportBatch::into_entries() provides an owned, source-order iterator.

Transport I/O failures are returned by the future from ConnectTo::into_channel_and_future; they are never channel items. Components should preserve a received frame intact when relaying it so batch response grouping remains correct.

TransportFrame::parse_json returns a frame directly for every input. It retains malformed response-shaped values so raw framed intermediaries can preserve them; protocol actors suppress replies to response-only shapes. Standalone malformed input keeps its exact source text. Batch entries retain parsed JSON values and source order, but reserialization may normalize whitespace.

The standard line, byte-stream, stdio, HTTP, and WebSocket transports now accept incoming JSON-RPC batches in both protocol v1 and v2 mode. Entries are handled independently, replies are grouped into one response array, notification-only batches produce no reply, and an empty array produces one standalone Invalid Request response. Malformed entries that are response-shaped but not call-shaped are ignored because JSON-RPC responses must not themselves receive responses. The SDK does not initiate batches of requests or notifications. See Transport Architecture for the full framing contract.

Dropping a Responder for one request in a batch now produces an Internal Error fallback after dispatch completes, allowing completed sibling responses to flush. A handler error overrides the fallback with that error. The longstanding behavior for an individual request is unchanged: dropping its responder does not automatically send a response.

Existing ConnectTo implementations that override into_channel_and_future must now return the frame-aware Channel. Most components need to implement only ConnectTo::connect_to; a direct channel adapter may still override into_channel_and_future to avoid an intermediate copy.

Response routing uses routing terminology

ResponseRouter completes a local pending request; it does not send a new JSON-RPC response. Its methods have therefore been renamed:

1.x2.0
respond_with_resultroute_with_result
respondroute
respond_with_errorroute_with_error
respond_with_internal_errorroute_with_internal_error

Responder still uses respond*, because it sends the response to an incoming request.

If a catch-all response handler returns Err, that error is now routed to the local SentRequest awaiter. It is never serialized as a response to the peer’s response. This replaces the misleading generic failure that previously appeared when the real interceptor error was lost.

Response callbacks enforce ordered dispatch

The 1.x documentation said that on_receiving_result and on_receiving_ok_result callbacks held the dispatch loop until completion, but the implementation did not enforce that ordering. In 2.0, registering either callback before a peer response is routed during its original dispatch selects ordered consumption: registration returns immediately, then the loop waits for response handling to finish before processing the next message.

This is a behavioral change. An ordered response callback must not await a later response, notification, or other inbound traffic on the same connection, because the loop cannot dispatch that traffic until the callback returns. Spawn that follow-up work and return from the callback, or use block_task from a task that already runs outside the dispatch loop, such as the foreground future passed to connect_with.

The barrier is not retroactive. A pending-request failure delivered without an incoming response (such as EOF), a response that was already routed, or a retained ResponseRouter routed after its original dispatch runs without holding the loop.

Session-start helpers apply this barrier only to framework-owned runner and routing installation, then spawn the user callback. This preserves the actual 1.x scheduling behavior for user session work while correcting the old documentation that claimed the user callback itself blocked dispatch.

Request IDs remain typed

Responder::id, ResponseRouter::id, and SentRequest::id now return &RequestId. Dispatch::id returns Option<&RequestId>. Clone the ID when it must outlive the handle:

#![allow(unused)]
fn main() {
let id = sent_request.id().clone();
}

This removes JSON round-trips and lets IDs pass directly to APIs such as request cancellation. If an integration still needs an untyped JSON value, serialize the borrowed ID explicitly:

#![allow(unused)]
fn main() {
let id_json = serde_json::to_value(sent_request.id())?;
let dispatch_id_json = dispatch.id().map(serde_json::to_value).transpose()?;
let _ = (id_json, dispatch_id_json);
}

Dynamic handlers use a guard

DynamicHandlerRegistration is now DynamicHandlerGuard and is exported from the crate root. The guard is must_use and no longer Clone: keep its single owner in the object that owns the registration. Dropping it unregisters the handler. To leave a handler registered for the rest of the connection, replace run_indefinitely() with detach(). Detaching no longer leaks an extra ConnectionTo handle.

Background tasks use runner terminology

Builder extensions implementing RunWithConnectionTo run alongside the connection; they do not respond to an individual JSON-RPC request. The builder method now reflects that distinction:

1.x2.0
Builder::with_responderBuilder::with_runner

The conductor crate applies the same terminology to its public background task:

1.x2.0
agent_client_protocol_conductor::ConductorResponderagent_client_protocol_conductor::ConductorRunner

This is a type rename only; custom code that names the conductor task should update its imports and type references.

Matchers use dispatch terminology

The combined matchers operate on Dispatch values, which can represent requests, notifications, or responses. Their method names now reflect that input:

1.x2.0
MatchDispatch::if_messageMatchDispatch::if_dispatch
MatchDispatchFrom::if_message_fromMatchDispatchFrom::if_dispatch_from

Connection and session accessors borrow

McpConnectionTo::acp_id is now server_id and returns Option<&McpServerAcpId>. The new name matches the native McpServer::Acp declaration; the Option reflects that a server can also be connected directly without ACP. connection_id returns an Option<&McpConnectionId> for the distinct active connection created by mcp/connect. Use context() to match explicitly on McpConnectionContext::Standalone or McpConnectionContext::Acp { server_id, connection_id }. The deprecated acp_url alias was removed. McpConnectionTo::connection_to is now connection and returns &ConnectionTo<_>.

ActiveSession::modes and ActiveSession::meta now return Option<&T> instead of &Option<T>, and ActiveSession::connection returns &ConnectionTo<_>.

These accessors avoid implicit allocation and handle cloning. Call .cloned() on server_id(), connection_id(), modes, or meta, and .clone() on either connection accessor when an owned value is required.

MCP servers use the native opt-in transport

The runtime-agnostic agent_client_protocol::mcp_server module remains available without an unstable ACP feature, so standalone MCP servers do not allocate or retain schema transport IDs. Enable the feature when attaching a server to ACP with Builder::with_mcp_server or SessionBuilder::with_mcp_server:

agent-client-protocol = { version = "2", features = ["unstable_mcp_over_acp"] }

agent-client-protocol-rmcp no longer enables this feature merely to build or directly serve an MCP server. Applications that attach an rmcp-backed server to ACP should enable its matching unstable_mcp_over_acp passthrough feature. The transport remains unstable and may change independently of the stable ACP surface.

In 1.x, the SDK represented an ACP-provided MCP server as McpServer::Http with an acp: URL and routed it through SDK-local underscore-prefixed methods. In 2.0, providers and native consumers use:

  • McpServer::Acp(McpServerAcp { name, server_id, .. }) in session setup requests;
  • mcp/connect with serverId, returning a distinct connectionId;
  • mcp/message requests and notifications keyed by that connection ID; and
  • a request/response mcp/disconnect exchange.

The low-level SDK-local McpConnectRequest, McpConnectResponse, McpOverAcpMessage, and McpDisconnectNotification types were removed. Use the feature-gated schema types instead:

1.x SDK-local type2.0 schema type
McpConnectRequestschema::v1::ConnectMcpRequest
McpConnectResponseschema::v1::ConnectMcpResponse
McpOverAcpMessage requestschema::v1::MessageMcpRequest
McpOverAcpMessage notificationschema::v1::MessageMcpNotification
McpDisconnectNotificationschema::v1::DisconnectMcpRequest and DisconnectMcpResponse

The public method-name constants moved to the schema’s generated method-name tables:

1.x SDK-local constant2.0 schema constant
METHOD_MCP_CONNECT_REQUESTschema::v1::CLIENT_METHOD_NAMES.mcp_connect
METHOD_MCP_MESSAGEschema::v1::CLIENT_METHOD_NAMES.mcp_message or AGENT_METHOD_NAMES.mcp_message, depending on direction
METHOD_MCP_DISCONNECT_NOTIFICATIONschema::v1::CLIENT_METHOD_NAMES.mcp_disconnect

Code using Builder::with_mcp_server or SessionBuilder::with_mcp_server continues to attach the high-level server in the same place; the emitted declaration and wire methods change. Global builder attachment advertises the same server ID on session/new, session/load, session/resume, and feature-gated session/fork. Stable v1 per-session attachment remains specific to session/new; draft v2 additionally supports per-session resume attachment through V2ResumeSessionBuilder::with_mcp_server and feature-gated fork attachment through V2ForkSessionBuilder::with_mcp_server. Do not construct an HTTP server with an acp: URL. If the final agent accepts HTTP but not native ACP MCP servers, insert McpOverAcpPolyfill immediately before it. The polyfill now consumes native McpServer::Acp declarations and adapts only its final-agent-facing side.

The polyfill’s public BridgeMode enum and McpOverAcpPolyfill::stdio were removed because the required conductor mcp helper subcommand no longer exists. The polyfill has one supported mode; construct it with McpOverAcpPolyfill::http() or Default, or manage a standard MCP transport separately.

Low-level helpers have a narrower surface

NullHandler::new was removed. Construct the unit struct as NullHandler or use NullHandler::default().

MatchDispatch::from_handled was removed because it exposed an internal composition state. Start a standalone match with MatchDispatch::new, or continue a peer-aware chain with MatchDispatchFrom.

Channel::copy is now an implementation detail. Connect channels through ConnectTo, or use Channel::bridge_with_inspection when building an inspecting relay.

DynConnectTo::type_name now returns &'static str without allocating. Call .to_owned() when an owned type name is required.

The generic util::both helper was removed. Replace util::both(a, b).await with futures::future::try_join(a, b).await.map(|((), ())| ()).

util::process_stream_concurrently is no longer public. An equivalent unbounded fallible loop can be written with futures::{StreamExt, TryStreamExt}:

stream
    .map(Ok::<_, agent_client_protocol::Error>)
    .try_for_each_concurrent(None, |item| process_fn(item))
    .await

Pass a finite limit instead of None to bound concurrency.

ConnectionTo::attach_session is no longer public. Create sessions through ConnectionTo::build_session, build_session_cwd, or build_session_from instead. Restore stable-v1 sessions through load_session, load_session_from, resume_session, or resume_session_from; blocking start_session returns a RestoredSession containing the ActiveSession and exact load or resume response, while on_session_start delivers that value to its callback. Both builder families expose on_session_start for non-blocking use and block_task().start_session() for tasks already outside the dispatch loop. Restore routing is acknowledged before the request is published so session/load replay cannot overtake local setup. For new sessions, SessionBuilder::run_until is also available after block_task(). Proxy handlers must use on_proxy_session_start; only call block_task().start_session_proxy(...) from a task already outside the dispatch loop, such as a connect_with foreground future or a spawned task. Directly attaching an already-returned NewSessionResponse is no longer supported, so move request customization into build_session_from before the builder sends session/new.

For new sessions, when the response is routed during its original dispatch, on_session_start and on_proxy_session_start install session routing under the ordered response callback, then spawn the user callback. No user callback code runs under that ordering guarantee. The callback itself must be 'static, but its returned future does not need an additional 'static bound and may safely wait for later connection or session traffic. A response interceptor that retains and routes the response later cannot retroactively order that setup before already-processed messages. Register application state needed for routing before calling these helpers. Bookkeeping that requires the returned session or session ID runs concurrently with later traffic. If handlers must observe ID-keyed bookkeeping first, install a gate or placeholder before calling the helper, have those handlers await it, and populate it from the callback.

Construct Lines and ByteStreams with Lines::new(outgoing, incoming) and ByteStreams::new(outgoing, incoming); their stream fields are no longer public.

Draft v2 schema updates

The optional unstable_protocol_v2 surface now tracks agent-client-protocol-schema 1.7. The changes accumulated across schema 1.5 through 1.7 are included in the SDK 2.0 migration because this API is explicitly unstable, rather than treated as stable-v1 wire changes.

  • Many values that were plain String or PathBuf fields are semantic newtypes, including AbsolutePath, MediaType, session/message/tool/terminal IDs, and list cursors. Construct them with .into() or their new methods, and use AsRef<Path> or as_ref() when borrowing their contents.
  • v2::DiffPatch.diff is now text. DiffPatch::new(text) remains the preferred constructor.
  • Terminal state is represented by Terminal, TerminalUpdate, TerminalOutput, TerminalOutputChunk, and TerminalExitStatus. SessionUpdate also has terminal update and output-chunk variants, so exhaustive matches must handle the new variants.
  • The experimental v2::conversion module and its cross-version helpers have been removed. Implement v1 and v2 handlers separately, and translate only application-owned shared state where the application’s semantics define a faithful mapping.

SentRequest::map accepts arbitrary output

SentRequest::map can now consume a typed response into any output type; the mapped value no longer needs to implement JsonRpcResponse. This includes mapped values carrying non-'static lifetimes when they are consumed with block_task; callback-style consumption still requires 'static because it is spawned onto the connection. The mapper may also be a one-shot closure. This is additive, so existing mapping code does not need to change.

AcpAgent has its own process configuration

AcpAgent now accepts AcpAgentConfig instead of the ACP wire-schema McpServer. An ACP agent subprocess is not an MCP server, and its local launch settings should not depend on the v1 protocol schema:

#![allow(unused)]
fn main() {
use agent_client_protocol::{AcpAgent, AcpAgentConfig};

let agent = AcpAgent::new(
    AcpAgentConfig::new("my-agent")
        .arg("--verbose")
        .env("RUST_LOG", "info"),
);
let configuration = agent.config();
let _ = configuration;
}

server() and into_server() are now config() and into_config(). The MCP-only name and _meta fields have no equivalents because they never affected process launching. Use command(), arguments(), and environment() to inspect the configuration.

The JSON configuration shape also changes. The MCP type, name, and _meta fields are removed, and environment variables change from schema objects:

{
  "type": "stdio",
  "name": "my-agent",
  "command": "python",
  "args": ["agent.py"],
  "env": [{ "name": "RUST_LOG", "value": "info" }]
}

to a string map:

{
  "command": "python",
  "args": ["agent.py"],
  "env": { "RUST_LOG": "info" }
}

Command-string and from_args construction are unchanged. HTTP and SSE McpServer variants were never valid subprocess launch configurations; callers using them must select an appropriate network transport separately.

The deprecated AcpAgent::zed_claude_code and AcpAgent::zed_codex constructors were removed; use claude_agent and codex. The google_gemini convenience constructor was also removed; replace it with the explicit command:

#![allow(unused)]
fn main() {
let agent = AcpAgent::from_args([
    "npx",
    "-y",
    "--",
    "@google/gemini-cli@latest",
    "--experimental-acp",
]).unwrap();
}

Migrating from agent-client-protocol 0.10.x to 0.11

This guide explains how to move existing code to the programming model planned for agent-client-protocol 0.11.

Historical note: later releases merged subprocess and stdio support into agent-client-protocol. The examples below use the current import path where that does not obscure the 0.11 migration itself.

Throughout this guide:

  • old API = agent-client-protocol 0.10.x
  • new API = the planned agent-client-protocol 0.11 API

All code snippets below use the intended 0.11 import paths.

1. Move message types under schema

The current 0.10.x crate exports most protocol message types at the crate root.

The 0.11 API moves most ACP request, response, and notification types under schema.

#![allow(unused)]
fn main() {
// Old (0.10.x)
use agent_client_protocol as acp;
use acp::{InitializeRequest, NewSessionRequest, PromptRequest, ProtocolVersion};

// New (0.11)
use agent_client_protocol as acp;
use acp::schema::{
    InitializeRequest, NewSessionRequest, PromptRequest, ProtocolVersion,
};
}

Most ACP request, response, and notification types live under schema in 0.11

2. Replace connection construction

The main construction changes are:

  • ClientSideConnection::new(handler, outgoing, incoming, spawn)
    • becomes Client.builder().connect_with(ByteStreams::new(outgoing, incoming), async |cx| { ... })
  • AgentSideConnection::new(handler, outgoing, incoming, spawn)
    • becomes Agent.builder().connect_to(ByteStreams::new(outgoing, incoming)).await?
  • custom spawn function + handle_io future
    • becomes builder-managed connection execution

If you already have stdin/stdout or socket-like byte streams, wrap them with ByteStreams::new(outgoing, incoming).

If you are spawning subprocess agents, prefer the core crate’s AcpAgent over hand-rolled process wiring.

If you already have a reason to stay at the raw request/response level, you can still send PromptRequest directly with cx.send_request(...); the session helpers are just the default migration path for most client code.

3. Replace outbound trait-style calls with send_request and send_notification

In the old API, the connection itself implemented the remote trait, so calling the other side looked like this:

#![allow(unused)]
fn main() {
conn.initialize(InitializeRequest::new(ProtocolVersion::V1)).await?;
}

In 0.11, you send a typed request through ConnectionTo<Peer>:

#![allow(unused)]
fn main() {
cx.send_request(InitializeRequest::new(ProtocolVersion::V1))
    .block_task()
    .await?;
}

The main replacements are:

Old styleNew style
conn.initialize(req).await?cx.send_request(req).block_task().await?
conn.new_session(req).await?usually cx.build_session(...) or cx.build_session_cwd()?
conn.prompt(req).await?usually session.send_prompt(...) on an ActiveSession
conn.cancel(notification).await?cx.send_notification(notification)?

A few behavioral differences matter during migration:

  • send_request(...) returns a SentRequest, not the response directly.
  • Call .block_task().await? when you want to wait for the response from a context that does not block the dispatch loop. The main_fn closure passed to connect_with(...) and tasks spawned via cx.spawn(...) are both safe; the dispatch loop continues processing messages (including the response you are waiting for) in the background.
  • Do not call .block_task().await? inside on_receive_* callbacks. Those callbacks run on the dispatch loop, so blocking them deadlocks the connection. Prefer on_receiving_result(...), on_receiving_ok_result(...), on_session_start(...), or cx.spawn(...) from handlers.

4. Replace manual session management with SessionBuilder

One of the biggest user-facing changes is session handling.

Old code typically looked like this:

#![allow(unused)]
fn main() {
let session = conn
    .new_session(NewSessionRequest::new(cwd))
    .await?;

conn.prompt(PromptRequest::new(
    session.session_id.clone(),
    vec!["Hello".into()],
))
.await?;
}

New code usually starts from the connection and uses a session builder:

#![allow(unused)]
fn main() {
cx.build_session_cwd()?
    .block_task()
    .run_until(async |mut session| {
        session.send_prompt("Hello")?;
        let output = session.read_to_string().await?;
        println!("{output}");
        Ok(())
    })
    .await?;
}

Useful replacements:

Old patternNew pattern
new_session(NewSessionRequest::new(cwd))build_session(cwd)
new_session(NewSessionRequest::new(current_dir))build_session_cwd()?
store session_id and pass it into every PromptRequestlet ActiveSession manage the session lifecycle
subscribe() to observe streamed session outputActiveSession::read_update() or ActiveSession::read_to_string()
intercept and rewrite a session/new request in a proxybuild_session_from(request)

Also note:

  • use start_session() when you want an ActiveSession<'static, _> you can keep around
  • use on_session_start(...) inside on_receive_* callbacks when you need to start a session without manually blocking the current task
  • use on_proxy_session_start(...) or start_session_proxy(...) for proxy-style session startup and forwarding

5. Replace Client trait impls with builder callbacks

In 0.10.x, your client behavior lived in an impl acp::Client for T block.

In 0.11, register typed handlers on Client.builder() instead.

Each on_receive_* call takes two arguments: the async handler, and one of the helper macros acp::on_receive_request!(), acp::on_receive_notification!(), or acp::on_receive_dispatch!(). These macros are a temporary workaround until return-type notation stabilizes; pass the one that matches the method you are calling.

Common client-side method mapping

  • request_permission -> .on_receive_request(|req: RequestPermissionRequest, responder, cx| ...)
  • write_text_file -> .on_receive_request(|req: WriteTextFileRequest, responder, cx| ...)
  • read_text_file -> .on_receive_request(|req: ReadTextFileRequest, responder, cx| ...)
  • create_terminal -> .on_receive_request(|req: CreateTerminalRequest, responder, cx| ...)
  • terminal_output -> .on_receive_request(|req: TerminalOutputRequest, responder, cx| ...)
  • release_terminal -> .on_receive_request(|req: ReleaseTerminalRequest, responder, cx| ...)
  • wait_for_terminal_exit -> .on_receive_request(|req: WaitForTerminalExitRequest, responder, cx| ...)
  • kill_terminal -> .on_receive_request(|req: KillTerminalRequest, responder, cx| ...)
  • session_notification -> .on_receive_notification(|notif: SessionNotification, cx| ...)
  • ext_method / ext_notification -> your own derived JsonRpcRequest / JsonRpcNotification types, or a catch-all on_receive_dispatch(...)

A small client-side translation looks like this:

use agent_client_protocol as acp;
use acp::schema::{
    RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
    SessionNotification,
};

#[tokio::main]
async fn main() -> acp::Result<()> {
    let transport = todo!("create the transport that connects to your agent");

    acp::Client
        .builder()
        .on_receive_request(
            async move |_: RequestPermissionRequest, responder, _cx| {
                responder.respond(RequestPermissionResponse::new(
                    RequestPermissionOutcome::Cancelled,
                ))
            },
            acp::on_receive_request!(),
        )
        .on_receive_notification(
            async move |notification: SessionNotification, _cx| {
                println!("{:?}", notification.update);
                Ok(())
            },
            acp::on_receive_notification!(),
        )
        .connect_with(transport, async |_cx: acp::ConnectionTo<acp::Agent>| {
            // send requests here, e.g. `_cx.send_request(...).block_task().await?`
            Ok(())
        })
        .await
}

6. Replace Agent trait impls with builder callbacks

The same shift applies on the agent side.

Common agent-side method mapping

  • initialize -> .on_receive_request(|req: InitializeRequest, responder, cx| ...)
  • authenticate -> .on_receive_request(|req: AuthenticateRequest, responder, cx| ...)
  • new_session -> .on_receive_request(|req: NewSessionRequest, responder, cx| ...)
  • prompt -> .on_receive_request(|req: PromptRequest, responder, cx| ...)
  • cancel -> .on_receive_notification(|notif: CancelNotification, cx| ...)
  • load_session -> .on_receive_request(|req: LoadSessionRequest, responder, cx| ...)
  • set_session_mode -> .on_receive_request(|req: SetSessionModeRequest, responder, cx| ...)
  • set_session_config_option -> .on_receive_request(|req: SetSessionConfigOptionRequest, responder, cx| ...)
  • list_sessions and other unstable session methods -> request handlers for the corresponding schema type
  • ext_method / ext_notification -> your own derived JsonRpcRequest / JsonRpcNotification types, or a catch-all on_receive_dispatch(...)

A minimal agent skeleton now looks like this:

use agent_client_protocol as acp;
use acp::schema::{
    AgentCapabilities, CancelNotification, InitializeRequest, InitializeResponse,
    PromptRequest, PromptResponse, StopReason,
};
use acp::{Client, Dispatch};

#[tokio::main]
async fn main() -> acp::Result<()> {
    let outgoing = todo!("create the agent's outgoing byte stream");
    let incoming = todo!("create the agent's incoming byte stream");

    acp::Agent
        .builder()
        .name("my-agent")
        .on_receive_request(
            async move |request: InitializeRequest, responder, _cx| {
                responder.respond(
                    InitializeResponse::new(request.protocol_version)
                        .agent_capabilities(AgentCapabilities::new()),
                )
            },
            acp::on_receive_request!(),
        )
        .on_receive_request(
            async move |_request: PromptRequest, responder, _cx| {
                responder.respond(PromptResponse::new(StopReason::EndTurn))
            },
            acp::on_receive_request!(),
        )
        .on_receive_notification(
            async move |_notification: CancelNotification, _cx| {
                Ok(())
            },
            acp::on_receive_notification!(),
        )
        .connect_to(acp::ByteStreams::new(outgoing, incoming))
        .await
}

If you need a catch-all handler, use on_receive_dispatch(...).

7. Replace subscribe() with session readers or explicit callbacks

There is no direct connection-level replacement for ClientSideConnection::subscribe().

Choose the replacement based on what you were using it for:

  • if you were reading prompt output for one session, prefer ActiveSession::read_update() or ActiveSession::read_to_string()
  • if you were observing inbound notifications generally, register on_receive_notification(...)
  • if you were forwarding or inspecting raw messages in a proxy, use on_receive_dispatch(...) plus send_proxied_message(...)

8. Remove LocalSet, spawn_local, and manual I/O tasks

The old crate examples needed a LocalSet because the connection futures were !Send.

Most migrations to the 0.11 API can remove:

  • tokio::task::LocalSet
  • tokio::task::spawn_local(...)
  • the custom spawn closure passed into connection construction
  • the separate handle_io future you had to drive manually

When you need concurrency from a handler in 0.11, use cx.spawn(...).

9. Prefer AcpAgent for subprocess agents

If your old client code spawned an agent with tokio::process::Command, the new stack has a higher-level helper for that. AcpAgent implements ConnectTo<Client> and takes care of spawning the process and wiring up its stdio:

use agent_client_protocol as acp;
use acp::schema::{InitializeRequest, ProtocolVersion};
use agent_client_protocol::AcpAgent;
use std::str::FromStr;

#[tokio::main]
async fn main() -> acp::Result<()> {
    let agent = AcpAgent::from_str("python my_agent.py")?;

    acp::Client
        .builder()
        .name("my-client")
        .connect_with(agent, async |cx| {
            cx.send_request(InitializeRequest::new(ProtocolVersion::V1))
                .block_task()
                .await?;
            // ...send more requests, build a session, etc.
            Ok(())
        })
        .await
}

Use connect_to(agent) (without a main_fn) only when the client has no outbound requests to send and just needs to keep the connection alive so registered handlers can respond to the agent. In that mode the builder runs until the transport closes or a handler returns an error.

You can still use ByteStreams::new(...) when you already own the byte streams and do not want the extra helper crate.

10. Common gotchas

  • Client and Agent are role markers now, not traits you implement.
  • The response type for send_request(...) is inferred from the request type.
  • send_request(...) does not wait by itself; use .block_task().await? or on_receiving_result(...).
  • Be careful about calling blocking operations from on_receive_* callbacks. Those callbacks run in the dispatch loop and preserve message ordering.
  • If your old code used subscribe() as a global message tap, plan a new strategy around ActiveSession, notification callbacks, or proxy dispatch handlers.
  • For reusable ACP components, implement ConnectTo<Role> instead of trying to recreate the old monolithic trait pattern.

Original P/ACP Design Proposal (Historical)

This document records the original proxying design and is retained for historical context. It contains method names and capability shapes that were superseded during implementation. Do not use it as a wire-protocol specification; see the current Protocol Reference instead.

In particular, the SDK-local _mcp/* methods and McpServer::Http values using an acp: URL were retired. Current opt-in implementations use McpServer::Acp with mcp/connect, mcp/message, and mcp/disconnect; the compatibility polyfill translates those native declarations only for HTTP-capable agents. Do not copy the historical MCP examples below.

Elevator pitch

What are you proposing to change?

We propose to prototype P/ACP (Proxying ACP), an extension to Zed’s Agent Client Protocol (ACP) that enables composable agent architectures through proxy chains. Instead of building monolithic AI tools, P/ACP allows developers to create modular components that can intercept and transform messages flowing between editors and agents.

This RFD builds on the concepts introduced in SymmACP: extending Zed’s ACP to support Composable Agents, with the protocol renamed to P/ACP for this implementation.

Key changes:

  • Define a proxy chain architecture where components can transform ACP messages
  • Create an orchestrator (Conductor) that manages the proxy chain and presents as a normal ACP agent to editors
  • Establish the _proxy/successor/* protocol for proxies to communicate with downstream components
  • Enable composition without requiring editors to understand P/ACP internals

Status quo

How do things work today and what problems does this cause? Why would we change things?

Today’s AI agent ecosystem is dominated by monolithic agents. We want people to be able to combine independent components to build custom agents targeting their specific needs. We want them to be able to use these with whatever editors and tooling they have. This is aligned with ACP’s core values of openness, interoperability, and extensibility.

Motivating Example: Sparkle Integration

Consider integrating Sparkle (a collaborative AI framework) into a coding session with Zed and Claude. Sparkle provides an MCP server with tools, but requires an initialization sequence to load patterns and set up collaborative context.

Without P/ACP:

  • Users must manually run the initialization sequence each session
  • Or use agent-specific hooks (Claude Code has them, but not standardized across agents)
  • Or modify the agent to handle initialization automatically
  • Result: Manual intervention required, agent-specific configuration, no generic solution

With P/ACP:

flowchart LR
    Editor[Editor<br/>Zed]

    subgraph Conductor[Conductor Orchestrator]
        Sparkle[Sparkle Component]
        Agent[Base Agent]
        MCP[Sparkle MCP Server]

        Sparkle -->|proxy chain| Agent
        Sparkle -.->|provides tools| MCP
    end

    Editor <-->|ACP| Conductor

The Sparkle component:

  1. Injects Sparkle MCP server into the agent’s tool list during initialize
  2. Intercepts the first prompt and prepends Sparkle embodiment sequence
  3. Passes all other messages through transparently

From the editor’s perspective, it talks to a normal ACP agent. From the base agent’s perspective, it has Sparkle tools available. No code changes required on either side.

This demonstrates P/ACP’s core value: adding capabilities through composition rather than modification.

What we propose to do about it

What are you proposing to improve the situation?

We will develop an extension to ACP called P/ACP (Proxying ACP).

The heart of P/ACP is a proxy chain where each component adds specific capabilities:

flowchart LR
    Editor[ACP Editor]

    subgraph Orchestrator[P/ACP Orchestrator]
        O[Orchestrator Process]
    end

    subgraph ProxyChain[Proxy Chain - managed by orchestrator]
        P1[Proxy 1]
        P2[Proxy 2]
        Agent[ACP Agent]

        P1 -->|_proxy/successor/*| P2
        P2 -->|_proxy/successor/*| Agent
    end

    Editor <-->|ACP| O
    O <-->|routes messages| ProxyChain

P/ACP defines three kinds of actors:

  • Editors spawn the orchestrator and communicate via standard ACP
  • Orchestrator manages the proxy chain, appears as a normal ACP agent to editors
  • Proxies intercept and transform messages, communicate with downstream via _proxy/successor/* protocol
  • Agents provide base AI model behavior using standard ACP

The orchestrator handles message routing, making the proxy chain transparent to editors. Proxies can transform requests, responses, or add side-effects without editors or agents needing P/ACP awareness.

The Orchestrator: Conductor

P/ACP’s orchestrator is called the Conductor (binary name: conductor). The conductor has three core responsibilities:

  1. Process Management - Creates and manages component processes based on command-line configuration
  2. Message Routing - Routes messages between editor, components, and agent through the proxy chain
  3. Capability Adaptation - Observes component capabilities and adapts between them

Key adaptation: MCP Bridge

  • If the agent supports mcpCapabilities.acp, conductor passes MCP servers with ACP transport through unchanged
  • If not, conductor spawns conductor mcp $port processes to bridge between stdio (MCP) and ACP messages
  • Components can provide MCP servers without requiring agent modifications
  • See “MCP Bridge” section in Implementation Details for full protocol

Other adaptations include session pre-population, streaming support, content types, and tool formats.

From the editor’s perspective, it spawns one conductor process and communicates using normal ACP over stdio. The editor doesn’t know about the proxy chain.

Command-line usage:

# Agent mode - manages proxy chain
conductor agent sparkle-acp claude-code-acp

# MCP mode - bridges stdio to TCP for MCP-over-ACP
conductor mcp 54321

To editors, the conductor is a normal ACP agent - no special capabilities are advertised upstream.

Proxy Capability Handshake:

The conductor uses a two-way capability handshake to verify that proxy components can fulfill their responsibilities:

  1. Conductor offers proxy capability - When initializing non-last components (proxies), the conductor includes "proxy": true in the _meta field of the InitializeRequest
  2. Component accepts proxy capability - The component must respond with "proxy": true in the _meta field of its InitializeResponse
  3. Last component (agent) - The final component is treated as a standard ACP agent and does NOT receive the proxy capability offer

Why a two-way handshake? The proxy capability is an active protocol - it requires the component to handle _proxy/successor/* messages and route communications appropriately. Unlike passive capabilities (like “http” or “sse”) which are just declarations, proxy components must actively participate in message routing. If a component doesn’t respond with the proxy capability, the conductor fails initialization with an error like “component X is not a proxy”, since that component cannot fulfill its required function in the chain.

Shiny future

How will things will play out once this feature exists?

Composable Agent Ecosystems

P/ACP enables a marketplace of reusable proxy components. Developers can:

  • Compose custom agent pipelines from independently-developed proxies
  • Share proxies across different editors and agents
  • Test and debug proxies in isolation
  • Mix community-developed and custom proxies

Simplified Agent Development

Agent developers can focus on core model behavior without implementing cross-cutting concerns:

  • Logging, metrics, and observability become proxy responsibilities
  • Rate limiting and caching handled externally
  • Content filtering and safety policies applied consistently

Editor Simplicity

Editors gain enhanced functionality without custom integrations:

  • Add sophisticated agent behaviors by changing proxy chain configuration
  • Support new agent features without editor updates
  • Maintain compatibility with any ACP agent

Standardization Path

As the ecosystem matures, successful patterns may be:

  • Standardized in ACP specification itself
  • Adopted by other agent protocols
  • Used as reference implementations for proxy architectures

Implemented Extensions

MCP Bridge - ✅ Implemented via the _mcp/* protocol (see “Implementation details and plan” section). Components can provide MCP servers using ACP transport, enabling tool provision without agents needing P/ACP awareness. The conductor bridges between agents lacking native support and components.

Future Protocol Extensions

Extensions under consideration for future development:

Agent-Initiated Messages - Allow components to send messages after the agent has sent end-turn, outside the normal request-response cycle. Use cases include background task completion notifications, time-based reminders, or autonomous checkpoint creation.

Session Pre-Population - Create sessions with existing conversation history. Conductor adapts based on agent capabilities: uses native support if available, otherwise synthesizes a dummy prompt containing the history, intercepts the response, and starts the real session.

Rich Content Types - Extend content types beyond text to include HTML panels, interactive GUI components, or other structured formats. Components can transform between content types based on what downstream agents support.

Implementation details and plan

Tell me more about your implementation. What is your detailed implementation plan?

The implementation focuses on building the Conductor and demonstrating the Sparkle integration use case.

P/ACP protocol

Definition: Editor vs Agent of a proxy

For an P/ACP proxy, the “editor” is defined as the upstream connection and the “agent” is the downstream connection.

flowchart LR
    Editor --> Proxy --> Agent

P/ACP editor capabilities

An P/ACP-aware editor provides the following capability during ACP initialization:

/// Including the symposium section *at all* means that the editor
/// supports symposium proxy initialization.
"_meta": {
    "symposium": {
        "version": "1.0"
    }
}

P/ACP proxies forward the capabilities they receive from their editor.

P/ACP component capabilities

P/ACP uses capabilities in the _meta field for the proxy handshake:

Proxy capability (two-way handshake):

The conductor offers the proxy capability to non-last components in InitializeRequest:

// InitializeRequest from conductor to proxy component
"_meta": {
    "symposium": {
        "version": "1.0",
        "proxy": true
    }
}

The component must accept by responding with the proxy capability in InitializeResponse:

// InitializeResponse from proxy component to conductor
"_meta": {
    "symposium": {
        "version": "1.0",
        "proxy": true
    }
}

If a component that was offered the proxy capability does not respond with it, the conductor fails initialization.

Agent capability: The last component in the chain (the agent) is NOT offered the proxy capability and does not need to respond with it. Agents are just normal ACP agents with no P/ACP awareness required.

The _proxy/successor/{send,receive} protocol

Proxies communicate with their downstream component (next proxy or agent) through special extension messages handled by the orchestrator:

_proxy/successor/send/request - Proxy wants to send a request downstream:

{
  "method": "_proxy/successor/send/request",
  "params": {
    "message": <ACP_REQUEST>
  }
}

_proxy/successor/send/notification - Proxy wants to send a notification downstream:

{
  "method": "_proxy/successor/send/notification",
  "params": {
    "message": <ACP_NOTIFICATION>
  }
}

_proxy/successor/receive/request - Orchestrator delivers a request from downstream:

{
  "method": "_proxy/successor/receive/request",
  "params": {
    "message": <ACP_REQUEST>
  }
}

_proxy/successor/receive/notification - Orchestrator delivers a notification from downstream:

{
  "method": "_proxy/successor/receive/notification",
  "params": {
    "message": <ACP_NOTIFICATION>
  }
}

Message flow example:

  1. Editor sends ACP prompt request to orchestrator
  2. Orchestrator forwards to Proxy1 as normal ACP message
  3. Proxy1 transforms and sends _proxy/successor/send/request { message: <modified_prompt> }
  4. Orchestrator routes that to Proxy2 as normal ACP prompt
  5. Eventually reaches agent, response flows back through chain
  6. Orchestrator wraps responses going upstream appropriately

Transparent proxy pattern: A pass-through proxy is trivial - just forward everything:

#![allow(unused)]
fn main() {
match message {
    // Forward requests from editor to successor
    AcpRequest(req) => send_to_successor_request(req),

    // Forward notifications from editor to successor
    AcpNotification(notif) => send_to_successor_notification(notif),

    // Forward from successor back to editor
    ExtRequest("_proxy/successor/receive/request", msg) => respond_to_editor(msg),
    ExtNotification("_proxy/successor/receive/notification", msg) => forward_to_editor(msg),
}
}

The MCP Bridge: _mcp/* Protocol

P/ACP enables components to provide MCP servers that communicate over ACP messages rather than traditional stdio. This allows components to handle MCP tool calls without agents needing special P/ACP awareness.

MCP Server Declaration with ACP Transport

Components declare MCP servers with ACP transport by using the HTTP MCP server format with a special URL scheme:

{
  "tools": {
    "mcpServers": {
      "sparkle": {
        "transport": "http",
        "url": "acp:550e8400-e29b-41d4-a716-446655440000",
        "headers": {}
      }
    }
  }
}

The acp:$UUID URL signals ACP transport. The component generates the UUID to identify which component handles calls to this MCP server.

Agent Capability: mcpCapabilities.acp

Agents that natively support MCP-over-ACP declare this capability:

{
  "agentCapabilities": {
    "mcpCapabilities": {
      "acp": true
    }
  }
}

Conductor behavior:

  • If the final agent has mcpCapabilities.acp: true, conductor passes MCP server declarations through unchanged
  • If the final agent lacks this capability, conductor performs bridging adaptation:
    1. Binds a fresh TCP port (e.g., localhost:54321)
    2. Transforms the MCP server declaration to use conductor mcp $port as the command
    3. Spawns conductor mcp $port which connects back via TCP and bridges to ACP messages
    4. Always advertises mcpCapabilities.acp: true to intermediate components

Bridging Transformation Example

Original MCP server spec (from component):

{
  "sparkle": {
    "transport": "http",
    "url": "acp:550e8400-e29b-41d4-a716-446655440000",
    "headers": {}
  }
}

Transformed spec (passed to agent without mcpCapabilities.acp):

{
  "sparkle": {
    "command": "conductor",
    "args": ["mcp", "54321"],
    "transport": "stdio"
  }
}

The agent thinks it’s talking to a normal MCP server over stdio. The conductor mcp process bridges between stdio (MCP JSON-RPC) and TCP (connection to main conductor), which then translates to ACP _mcp/* messages.

MCP Message Flow Protocol

When MCP tool calls occur, they flow as ACP extension messages:

_mcp/client_to_server/request - Agent calling an MCP tool (flows backward up chain):

{
  "jsonrpc": "2.0",
  "id": "T1",
  "method": "_mcp/client_to_server/request",
  "params": {
    "url": "acp:550e8400-e29b-41d4-a716-446655440000",
    "message": {
      "jsonrpc": "2.0",
      "id": "mcp-123",
      "method": "tools/call",
      "params": {
        "name": "embody_sparkle",
        "arguments": {}
      }
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": "T1",
  "result": {
    "message": {
      "jsonrpc": "2.0",
      "id": "mcp-123",
      "result": {
        "content": [{ "type": "text", "text": "Embodiment complete" }]
      }
    }
  }
}

_mcp/client_to_server/notification - Agent sending notification to MCP server:

{
  "jsonrpc": "2.0",
  "method": "_mcp/client_to_server/notification",
  "params": {
    "url": "acp:550e8400-e29b-41d4-a716-446655440000",
    "message": {
      "jsonrpc": "2.0",
      "method": "notifications/cancelled",
      "params": {}
    }
  }
}

_mcp/server_to_client/request - MCP server calling back to agent (flows forward down chain):

{
  "jsonrpc": "2.0",
  "id": "S1",
  "method": "_mcp/server_to_client/request",
  "params": {
    "url": "acp:550e8400-e29b-41d4-a716-446655440000",
    "message": {
      "jsonrpc": "2.0",
      "id": "mcp-456",
      "method": "sampling/createMessage",
      "params": {
        "messages": [...],
        "modelPreferences": {...}
      }
    }
  }
}

_mcp/server_to_client/notification - MCP server sending notification to agent:

{
  "jsonrpc": "2.0",
  "method": "_mcp/server_to_client/notification",
  "params": {
    "url": "acp:550e8400-e29b-41d4-a716-446655440000",
    "message": {
      "jsonrpc": "2.0",
      "method": "notifications/progress",
      "params": {
        "progressToken": "token-1",
        "progress": 50,
        "total": 100
      }
    }
  }
}

Message Routing

Client→Server messages (agent calling MCP tools):

  • Flow backward up the proxy chain (agent → conductor → components)
  • Component matches on params.url to identify which MCP server
  • Component extracts params.message, handles the MCP call, responds

Server→Client messages (MCP server callbacks):

  • Flow forward down the proxy chain (component → conductor → agent)
  • Component initiates when its MCP server needs to call back (sampling, logging, progress)
  • Conductor routes to agent (or via bridge if needed)

Conductor MCP Mode

The conductor binary has two modes:

  1. Agent mode: conductor agent [proxies...] agent

    • Manages P/ACP proxy chain
    • Routes ACP messages
  2. MCP mode: conductor mcp $port

    • Acts as MCP server over stdio
    • Connects to localhost:$port via TCP
    • Bridges MCP JSON-RPC (stdio) ↔ raw JSON-RPC (TCP to main conductor)

When bridging is needed, the main conductor spawns conductor mcp $port as the child process that the agent communicates with via stdio.

Additional Extension Messages

Proxies can define their own extension messages beyond _proxy/successor/* to provide specific capabilities. Examples might include:

  • Logging/observability: _proxy/log messages for structured logging
  • Metrics: _proxy/metric messages for tracking usage
  • Configuration: _proxy/config messages for dynamic reconfiguration

The orchestrator can handle routing these messages appropriately, or they can be handled by specific proxies in the chain.

These extensions are beyond the scope of this initial RFD and will be defined as needed by specific proxy implementations.

Implementation progress

What is the current status of implementation and what are the next steps?

Current Status: Implementation Phase

Completed:

  • ✅ P/ACP protocol design with Conductor orchestrator architecture
  • _proxy/successor/{send,receive} message protocol defined
  • scp Rust crate with JSON-RPC layer and ACP message types
  • ✅ Comprehensive JSON-RPC test suite (21 tests)
  • ✅ Proxy message type definitions (ToSuccessorRequest, etc.)

In Progress:

  • Conductor orchestrator implementation
  • Sparkle P/ACP component
  • MCP Bridge implementation (see checklist below)

MCP Bridge Implementation Checklist

Phase 1: Conductor MCP Mode (COMPLETE ✅)

  • Implement conductor mcp $port CLI parsing
  • TCP connection to localhost:$port
  • Stdio → TCP bridging (read from stdin, send via TCP)
  • TCP → Stdio bridging (read from TCP, write to stdout)
  • Newline-delimited JSON framing
  • Error handling (connection failures, parse errors, reconnection logic)
  • Unit tests for message bridging
  • Integration test: standalone MCP bridge with mock MCP client/server

Phase 2: Conductor Agent Mode - MCP Detection & Bridging

  • Detect "transport": "http", "url": "acp:$UUID" MCP servers in initialization
  • Check final agent for mcpCapabilities.acp capability
  • Bind ephemeral TCP ports when bridging needed
  • Transform MCP server specs to use conductor mcp $port
  • Spawn conductor mcp $port subprocess per ACP-transport MCP server
  • Store mapping: UUID → TCP port → bridge process
  • Always advertise mcpCapabilities.acp: true to intermediate components
  • Integration test: full chain with MCP bridging

Phase 3: _mcp/* Message Routing

  • Route _mcp/client_to_server/request (TCP → ACP, backward up chain)
  • Route _mcp/client_to_server/notification (TCP → ACP, backward)
  • Route _mcp/server_to_client/request (ACP → TCP, forward down chain)
  • Route _mcp/server_to_client/notification (ACP → TCP, forward)
  • URL matching for component routing (params.url matches UUID)
  • Response routing back through bridge
  • Integration test: full _mcp/* message flow

Phase 4: Bridge Lifecycle Management

  • Clean up bridge processes on session end
  • Handle bridge process crashes
  • Handle component crashes (clean up associated bridges)
  • TCP connection cleanup on errors
  • Port cleanup and reuse

Phase 5: Component-Side MCP Integration

  • Sparkle component declares ACP-transport MCP server
  • Sparkle handles _mcp/client_to_server/* messages
  • Sparkle initiates _mcp/server_to_client/* callbacks
  • End-to-end test: Sparkle embodiment via MCP bridge

Phase 1: Minimal Sparkle Demo

Goal: Demonstrate Sparkle integration through P/ACP composition.

Components:

  1. Conductor orchestrator - Process management, message routing, capability adaptation
  2. Sparkle P/ACP component - Injects Sparkle MCP server, handles embodiment sequence
  3. Integration test - Validates end-to-end flow with mock editor/agent

Demo flow:

Zed → Conductor → Sparkle Component → Claude
                ↓
           Sparkle MCP Server

Success criteria:

  • Sparkle MCP server appears in agent’s tool list
  • First prompt triggers Sparkle embodiment sequence
  • Subsequent prompts work normally
  • All other ACP messages pass through unchanged

Detailed MVP Walkthrough

This section shows the exact message flows for the minimal Sparkle demo.

Understanding UUIDs in the flow:

There are two distinct types of UUIDs in these sequences:

  1. Message IDs (JSON-RPC request IDs): These identify individual JSON-RPC requests and must be tracked to route responses correctly. When a component forwards a message using _proxy/successor/request, it creates a fresh message ID for the downstream request and remembers the mapping to route the response back.

  2. Session IDs (ACP session identifiers): These identify ACP sessions and flow through the chain unchanged. The agent creates a session ID, and all components pass it back unmodified.

Conductor’s routing rules:

  1. Message from Editor → Forward “as is” to first component (same message ID)
  2. _proxy/successor/request from component → Unwrap payload and send to next component (using message ID from the wrapper)
  3. Response from downstream → Send back to whoever made the _proxy request
  4. First component’s response → Send back to Editor

Components don’t talk directly to each other - all communication flows through Conductor via the _proxy protocol.

Scenario 1: Initialization and Session Creation

The editor spawns Conductor with component names, Conductor spawns the components, and initialization flows through the chain.

sequenceDiagram
    participant Editor as Editor<br/>(Zed)
    participant Conductor as Conductor<br/>Orchestrator
    participant Sparkle as Sparkle<br/>Component
    participant Agent as Base<br/>Agent

    Note over Editor: Spawns Conductor with args:<br/>"sparkle-acp agent-acp"
    Editor->>Conductor: spawn process
    activate Conductor

    Note over Conductor: Spawns both components
    Conductor->>Sparkle: spawn "sparkle-acp"
    activate Sparkle
    Conductor->>Agent: spawn "agent-acp"
    activate Agent

    Note over Editor,Agent: === Initialization Phase ===

    Editor->>Conductor: initialize (id: I0)
    Conductor->>Sparkle: initialize (id: I0)<br/>(offers PROXY capability)

    Note over Sparkle: Sees proxy capability offer,<br/>initializes successor

    Sparkle->>Conductor: _proxy/successor/request (id: I1)<br/>payload: initialize
    Conductor->>Agent: initialize (id: I1)<br/>(NO proxy capability - agent is last)
    Agent-->>Conductor: initialize response (id: I1)
    Conductor-->>Sparkle: _proxy/successor response (id: I1)

    Note over Sparkle: Sees Agent capabilities,<br/>prepares response

    Sparkle-->>Conductor: initialize response (id: I0)<br/>(accepts PROXY capability)

    Note over Conductor: Verifies Sparkle accepted proxy.<br/>If not, would fail with error.

    Conductor-->>Editor: initialize response (id: I0)

    Note over Editor,Agent: === Session Creation ===

    Editor->>Conductor: session/new (id: U0, tools: M0)
    Conductor->>Sparkle: session/new (id: U0, tools: M0)

    Note over Sparkle: Wants to inject Sparkle MCP server

    Sparkle->>Conductor: _proxy/successor/request (id: U1)<br/>payload: session/new with tools (M0, sparkle-mcp)
    Conductor->>Agent: session/new (id: U1, tools: M0 + sparkle-mcp)

    Agent-->>Conductor: response (id: U1, sessionId: S1)
    Conductor-->>Sparkle: response to _proxy request (id: U1, sessionId: S1)

    Note over Sparkle: Remembers mapping U0 → U1

    Sparkle-->>Conductor: response (id: U0, sessionId: S1)
    Conductor-->>Editor: response (id: U0, sessionId: S1)

    Note over Editor,Agent: Session S1 created,<br/>Sparkle MCP server available to agent

Key messages:

  1. Editor → Conductor: initialize (id: I0)

    {
      "jsonrpc": "2.0",
      "id": "I0",
      "method": "initialize",
      "params": {
        "protocolVersion": "0.1.0",
        "capabilities": {},
        "clientInfo": { "name": "Zed", "version": "0.1.0" }
      }
    }
    
  2. Conductor → Sparkle: initialize (id: I0, with PROXY capability)

    {
      "jsonrpc": "2.0",
      "id": "I0",
      "method": "initialize",
      "params": {
        "protocolVersion": "0.1.0",
        "capabilities": {
          "_meta": {
            "symposium": {
              "version": "1.0",
              "proxy": true
            }
          }
        },
        "clientInfo": { "name": "Conductor", "version": "0.1.0" }
      }
    }
    
  3. Sparkle → Conductor: _proxy/successor/request (id: I1, wrapping initialize)

    {
      "jsonrpc": "2.0",
      "id": "I1",
      "method": "_proxy/successor/request",
      "params": {
        "message": {
          "method": "initialize",
          "params": {
            "protocolVersion": "0.1.0",
            "capabilities": {},
            "clientInfo": { "name": "Sparkle", "version": "0.1.0" }
          }
        }
      }
    }
    
  4. Conductor → Agent: initialize (id: I1, unwrapped, without PROXY capability)

    {
      "jsonrpc": "2.0",
      "id": "I1",
      "method": "initialize",
      "params": {
        "protocolVersion": "0.1.0",
        "capabilities": {},
        "clientInfo": { "name": "Sparkle", "version": "0.1.0" }
      }
    }
    
  5. Agent → Conductor: initialize response (id: I1)

    {
      "jsonrpc": "2.0",
      "id": "I1",
      "result": {
        "protocolVersion": "0.1.0",
        "capabilities": {},
        "serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
      }
    }
    
  6. Conductor → Sparkle: _proxy/successor response (id: I1, wrapping Agent’s response)

    {
      "jsonrpc": "2.0",
      "id": "I1",
      "result": {
        "protocolVersion": "0.1.0",
        "capabilities": {},
        "serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
      }
    }
    
  7. Sparkle → Conductor: initialize response (id: I0, accepting proxy capability)

    {
      "jsonrpc": "2.0",
      "id": "I0",
      "result": {
        "protocolVersion": "0.1.0",
        "capabilities": {
          "_meta": {
            "symposium": {
              "version": "1.0",
              "proxy": true
            }
          }
        },
        "serverInfo": { "name": "Sparkle + claude-code-acp", "version": "0.1.0" }
      }
    }
    

    Note: Sparkle MUST include "proxy": true in its response since it was offered the proxy capability. If this field is missing, Conductor will fail initialization with an error.

  8. Editor → Conductor: session/new (id: U0)

    {
      "jsonrpc": "2.0",
      "id": "U0",
      "method": "session/new",
      "params": {
        "tools": {
          "mcpServers": {
            "filesystem": { "command": "mcp-filesystem", "args": [] }
          }
        }
      }
    }
    
  9. Conductor → Sparkle: session/new (id: U0, forwarded as-is)

    {
      "jsonrpc": "2.0",
      "id": "U0",
      "method": "session/new",
      "params": {
        "tools": {
          "mcpServers": {
            "filesystem": { "command": "mcp-filesystem", "args": [] }
          }
        }
      }
    }
    
  10. Sparkle → Conductor: _proxy/successor/request (id: U1, with injected Sparkle MCP)

{
  "jsonrpc": "2.0",
  "id": "U1",
  "method": "_proxy/successor/request",
  "params": {
    "message": {
      "method": "session/new",
      "params": {
        "tools": {
          "mcpServers": {
            "filesystem": { "command": "mcp-filesystem", "args": [] },
            "sparkle": { "command": "sparkle-mcp", "args": [] }
          }
        }
      }
    }
  }
}
  1. Conductor → Agent: session/new (id: U1, unwrapped from _proxy message)
{
  "jsonrpc": "2.0",
  "id": "U1",
  "method": "session/new",
  "params": {
    "tools": {
      "mcpServers": {
        "filesystem": { "command": "mcp-filesystem", "args": [] },
        "sparkle": { "command": "sparkle-mcp", "args": [] }
      }
    }
  }
}
  1. Agent → Conductor: response (id: U1, with new session S1)
{
  "jsonrpc": "2.0",
  "id": "U1",
  "result": {
    "sessionId": "S1",
    "serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
  }
}
  1. Conductor → Sparkle: _proxy/successor response (id: U1)
{
  "jsonrpc": "2.0",
  "id": "U1",
  "result": {
    "sessionId": "S1",
    "serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
  }
}
  1. Sparkle → Conductor: response (id: U0, with session S1)
{
  "jsonrpc": "2.0",
  "id": "U0",
  "result": {
    "sessionId": "S1",
    "serverInfo": { "name": "Conductor + Sparkle", "version": "0.1.0" }
  }
}

Scenario 2: First Prompt (Sparkle Embodiment)

When the first prompt arrives, Sparkle intercepts it and runs the embodiment sequence before forwarding the actual user prompt.

sequenceDiagram
    participant Editor as Editor<br/>(Zed)
    participant Conductor as Conductor<br/>Orchestrator
    participant Sparkle as Sparkle<br/>Component
    participant Agent as Base<br/>Agent

    Note over Editor,Agent: === First Prompt Flow ===

    Editor->>Conductor: session/prompt (id: P0, sessionId: S1)
    Conductor->>Sparkle: session/prompt (id: P0, sessionId: S1)

    Note over Sparkle: First prompt detected!<br/>Run embodiment sequence first

    Sparkle->>Conductor: _proxy/successor/request (id: P1)<br/>payload: session/prompt (embodiment)
    Conductor->>Agent: session/prompt (id: P1, embodiment)

    Agent-->>Conductor: response (id: P1, tool_use: embody_sparkle)
    Conductor-->>Sparkle: response to _proxy request (id: P1)

    Note over Sparkle: Embodiment complete,<br/>now send real prompt

    Sparkle->>Conductor: _proxy/successor/request (id: P2)<br/>payload: session/prompt (user message)
    Conductor->>Agent: session/prompt (id: P2, user message)

    Agent-->>Conductor: response (id: P2, actual answer)
    Conductor-->>Sparkle: response to _proxy request (id: P2)

    Note over Sparkle: Maps P2 → P0

    Sparkle-->>Conductor: response (id: P0, actual answer)
    Conductor-->>Editor: response (id: P0, actual answer)

    Note over Editor,Agent: User sees response,<br/>Sparkle initialized

Key messages:

  1. Editor → Conductor: session/prompt (id: P0, user’s first message)

    {
      "jsonrpc": "2.0",
      "id": "P0",
      "method": "session/prompt",
      "params": {
        "sessionId": "S1",
        "messages": [
          { "role": "user", "content": "Hello! Can you help me with my code?" }
        ]
      }
    }
    
  2. Conductor → Sparkle: session/prompt (id: P0, forwarded as-is)

    {
      "jsonrpc": "2.0",
      "id": "P0",
      "method": "session/prompt",
      "params": {
        "sessionId": "S1",
        "messages": [
          { "role": "user", "content": "Hello! Can you help me with my code?" }
        ]
      }
    }
    
  3. Sparkle → Conductor: _proxy/successor/request (id: P1, embodiment sequence)

    {
      "jsonrpc": "2.0",
      "id": "P1",
      "method": "_proxy/successor/request",
      "params": {
        "message": {
          "method": "session/prompt",
          "params": {
            "sessionId": "S1",
            "messages": [
              {
                "role": "user",
                "content": "Please use the embody_sparkle tool to load your collaborative patterns."
              }
            ]
          }
        }
      }
    }
    
  4. Conductor → Agent: session/prompt (id: P1, unwrapped embodiment)

    {
      "jsonrpc": "2.0",
      "id": "P1",
      "method": "session/prompt",
      "params": {
        "sessionId": "S1",
        "messages": [
          {
            "role": "user",
            "content": "Please use the embody_sparkle tool to load your collaborative patterns."
          }
        ]
      }
    }
    
  5. Agent → Conductor: response (id: P1, embodiment tool call)

    {
      "jsonrpc": "2.0",
      "id": "P1",
      "result": {
        "role": "assistant",
        "content": [
          {
            "type": "tool_use",
            "id": "tool-1",
            "name": "embody_sparkle",
            "input": {}
          }
        ]
      }
    }
    
  6. Sparkle → Conductor: _proxy/successor/request (id: P2, actual user prompt)

    {
      "jsonrpc": "2.0",
      "id": "P2",
      "method": "_proxy/successor/request",
      "params": {
        "message": {
          "method": "session/prompt",
          "params": {
            "sessionId": "S1",
            "messages": [
              {
                "role": "user",
                "content": "Hello! Can you help me with my code?"
              }
            ]
          }
        }
      }
    }
    
  7. Conductor → Agent: session/prompt (id: P2, unwrapped user prompt)

    {
      "jsonrpc": "2.0",
      "id": "P2",
      "method": "session/prompt",
      "params": {
        "sessionId": "S1",
        "messages": [
          { "role": "user", "content": "Hello! Can you help me with my code?" }
        ]
      }
    }
    
  8. Sparkle → Conductor: response (id: P0, forwarded to editor)

    {
      "jsonrpc": "2.0",
      "id": "P0",
      "result": {
        "role": "assistant",
        "content": "I'd be happy to help you with your code! What would you like to work on?"
      }
    }
    

Scenario 3: Subsequent Prompts (Pass-Through)

After embodiment, Sparkle passes all messages through transparently.

sequenceDiagram
    participant Editor as Editor<br/>(Zed)
    participant Conductor as Conductor<br/>Orchestrator
    participant Sparkle as Sparkle<br/>Component
    participant Agent as Base<br/>Agent

    Note over Editor,Agent: === Subsequent Prompt Flow ===

    Editor->>Conductor: session/prompt (id: P3, sessionId: S1)
    Conductor->>Sparkle: session/prompt (id: P3, sessionId: S1)

    Note over Sparkle: Already embodied,<br/>pass through unchanged

    Sparkle->>Conductor: _proxy/successor/request (id: P4)<br/>payload: session/prompt (unchanged)
    Conductor->>Agent: session/prompt (id: P4, unchanged)

    Agent-->>Conductor: response (id: P4)
    Conductor-->>Sparkle: response to _proxy request (id: P4)

    Note over Sparkle: Maps P4 → P3

    Sparkle-->>Conductor: response (id: P3)
    Conductor-->>Editor: response (id: P3)

    Note over Editor,Agent: Normal ACP flow,<br/>Sparkle and Conductor transparent

Key messages:

  1. Editor → Conductor: session/prompt (id: P3)

    {
      "jsonrpc": "2.0",
      "id": "P3",
      "method": "session/prompt",
      "params": {
        "sessionId": "S1",
        "messages": [
          {
            "role": "user",
            "content": "Can you refactor the authenticate function?"
          }
        ]
      }
    }
    
  2. Sparkle → Conductor: _proxy/successor/request (id: P4, message unchanged)

    {
      "jsonrpc": "2.0",
      "id": "P4",
      "method": "_proxy/successor/request",
      "params": {
        "message": {
          "method": "session/prompt",
          "params": {
            "sessionId": "S1",
            "messages": [
              {
                "role": "user",
                "content": "Can you refactor the authenticate function?"
              }
            ]
          }
        }
      }
    }
    
  3. Conductor → Agent: session/prompt (id: P4, unwrapped)

    {
      "jsonrpc": "2.0",
      "id": "P4",
      "method": "session/prompt",
      "params": {
        "sessionId": "S1",
        "messages": [
          {
            "role": "user",
            "content": "Can you refactor the authenticate function?"
          }
        ]
      }
    }
    
  4. Sparkle → Conductor: response (id: P3, forwarded to editor)

    {
      "jsonrpc": "2.0",
      "id": "P3",
      "result": {
        "role": "assistant",
        "content": "I'll help you refactor the authenticate function..."
      }
    }
    

Note that even though Sparkle is passing messages through “transparently”, it still uses the _proxy/successor/request protocol. This maintains the consistent routing pattern where all downstream communication flows through Conductor.

Implementation Note on Embodiment Responses:

For the MVP, when Sparkle runs the embodiment sequence before the user’s actual prompt, it will buffer both responses and concatenate them before sending back to the editor. This makes the embodiment transparent but loses some structure. A future RFD will explore richer content types (like subconversation) that would allow editors to distinguish between nested exchanges and main responses.

Phase 2: Tool Interception (FUTURE)

Goal: Route MCP tool calls through the proxy chain.

Conductor registers as a dummy MCP server. When Claude calls a Sparkle tool, the call routes back through the proxy chain to the Sparkle component for handling. This enables richer component interactions without requiring agents to understand P/ACP.

Phase 3: Additional Components (FUTURE)

Build additional P/ACP components that demonstrate different use cases:

  • Session history/context management
  • Logging and observability
  • Rate limiting
  • Content filtering

These will validate the protocol design and inform refinements.

Testing Strategy

Unit tests:

  • Test message serialization/deserialization
  • Test process spawning logic
  • Test stdio communication

Integration tests:

  • Spawn real proxy chains
  • Use actual ACP agents for end-to-end validation
  • Test error handling and cleanup

Manual testing:

  • Use with VSCode + ACP-aware agents
  • Verify with different proxy configurations
  • Test process management under various failure modes

Frequently asked questions

What questions have arisen over the course of authoring this document or during subsequent discussions?

What alternative approaches did you consider, and why did you settle on this one?

We considered extending MCP directly, but MCP is focused on tool provision rather than conversation flow control. We also looked at building everything as VSCode extensions, but that would lock us into a single editor ecosystem.

P/ACP’s proxy chain approach provides the right balance of modularity and compatibility - components can be developed independently while still working together.

How does this relate to other agent protocols like Google’s A2A?

P/ACP is complementary to protocols like A2A. While A2A focuses on agent-to-agent communication for remote services, P/ACP focuses on composing the user-facing development experience. You could imagine P/ACP components that use A2A internally to coordinate with remote agents.

What about security concerns with arbitrary proxy chains?

Users are responsible for the proxies they choose to run, similar to how they’re responsible for the software they install. Proxies can intercept and modify all communication, so trust is essential. For future versions, we’re considering approaches like Microsoft’s Wassette (WASM-based capability restrictions) to provide sandboxed execution environments.

What about the chat GUI interface?

We currently have a minimal chat GUI working in VSCode that can exchange basic messages with ACP agents. However, a richer chat interface with features like message history, streaming support, context providers, and interactive elements remains TBD.

Continue.dev has solved many of the hard problems for production-quality chat interfaces in VS Code extensions. Their GUI is specifically designed to be reusable - they use the exact same codebase for both VS Code and JetBrains IDEs by implementing different adapter layers.

Their architecture proves that message-passing protocols can cleanly separate GUI concerns from backend logic, which aligns perfectly with P/ACP’s composable design. When we’re ready to enhance the chat interface, we can evaluate whether to build on Continue.dev’s foundation or develop our own approach based on what we learn from the P/ACP proxy framework.

The Apache 2.0 license makes this legally straightforward, and their well-documented message protocols provide a clear integration path.

Why not just use hooks or plugins?

Hooks are fundamentally limited to what the host application anticipated. P/ACP proxies can intercept and modify the entire conversation flow, enabling innovations that the original tool designer never envisioned. This is the difference between customization and true composability.

What about performance implications of the proxy chain?

The proxy chain does add some latency as messages pass through multiple hops. However, we don’t expect this to be noticeable for typical development workflows. Most interactions are human-paced rather than high-frequency, and the benefits of composability outweigh the minimal latency cost.

How will users discover and configure proxy chains?

This will be determined over time as the ecosystem develops. We expect solutions to emerge organically, potentially including registries, configuration files, or marketplace-style discovery mechanisms.

What about resource management with multiple proxy processes?

Each proxy manages the lifecycle of processes it starts. When a proxy terminates, it cleans up its downstream processes. This creates a natural cleanup chain that prevents resource leaks.

Revision history

Initial draft based on architectural discussions.