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:
agent-client-protocol- Core SDK for building clients, agents, and proxiesagent-client-protocol-cookbook- Practical patterns and examplesagent-client-protocol-conductor- Running proxy chains
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 - How the core crate and its transport and integration crates are organized
- Transport Architecture - The frame-aware boundary shared by transports and in-process components
- Conductor Design - How the conductor orchestrates proxy chains
- Protocol Reference - Wire protocol details and extension methods
- MCP Bridge - Adapting native MCP-over-ACP for HTTP-capable agents
- Original P/ACP Design Proposal - Historical design context; not the current wire reference
- Migrating to v2.0 - Upgrade guide from 1.x to 2.0
- Migrating to v0.11 - Upgrade guide from 0.10.x to 0.11
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_acptransport
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 codeMcpServer::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:
| Role | Counterpart |
|---|---|
Client | Agent |
Agent | Client |
Proxy | Conductor |
Conductor | Proxy |
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:
- Messages are processed one at a time
- A handler runs to completion before the next message is processed
- 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_closedand 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, andis_incoming_closed()reports whether it has completed.Builder::on_close()runs cleanup callbacks in registration order. Returning an error terminates a still-runningconnect_withforeground; returningOk(())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
| File | Purpose |
|---|---|
src/agent-client-protocol/src/role.rs | Role trait and type definitions |
src/agent-client-protocol/src/role/acp.rs | Client, Agent, Proxy, Conductor roles |
src/agent-client-protocol/src/component.rs | ConnectTo component abstraction |
src/agent-client-protocol/src/jsonrpc.rs | Connection builder and frame types |
src/agent-client-protocol/src/jsonrpc/handlers.rs | Handler chain implementation |
src/agent-client-protocol/src/jsonrpc/transport_actor.rs | Line framing and JSON parsing |
src/agent-client-protocol/src/util/typed.rs | Dispatch 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
Why Roles Instead of Links?
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
| Method | JSON-RPC shape | Purpose |
|---|---|---|
_proxy/initialize | request | Initialize a component as a proxy |
_proxy/successor | request or notification | Forward one inner ACP message to the next component |
mcp/connect | request | Open a connection to an ACP-provided MCP server |
mcp/message | request or notification | Carry one inner MCP message over ACP |
mcp/disconnect | request | Close 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": {}
}
Related Documentation
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:
- The client sends
$/cancel_requestfor a request it made to its direct peer (for example, a proxy). - A proxy that forwarded the request downstream (the SDK does this with
forward_response_to) reacts by sending its own$/cancel_requestfor the downstream request, using the downstream connection’s request ID. - 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.
Related Documentation
- Protocol Reference - The
_proxy/successorenvelope protocol - agent-client-protocol rustdoc - SDK API for sending, observing, and forwarding cancellations (see
concepts::cancellation)
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/listdiscovers configurable providers, supported API protocols, whether each provider is required, and its current non-secret routing target.providers/setreplaces one provider’s API protocol, base URL, and complete header map.providers/disabledisables 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/promptreturns aPromptResponseas soon as the agent accepts the prompt.V2Session::send_promptreturns that request as aSentRequest<PromptResponse>; callers must explicitly await it, register a response callback, or detach it.V2SessionBuilder::start_sessionlikewise returns a mappedSentRequest. ItsOpenedV2Sessionresult keeps the command handle separate from the completeNewSessionResponserepresented by the linked schema, rather than reconstructing a selected subset of its fields.V2ConnectionTo::resume_sessionandresume_session_fromreturn aV2ResumeSessionBuilder. Itsstart_sessionmethod publishessession/resumeand returns anOpenedV2Sessioncontaining the completeResumeSessionResponsewithout reconstructing it.- With
unstable_session_fork,V2ConnectionTo::fork_sessionandfork_session_fromreturn aV2ForkSessionBuilder. Itsstart_sessionpublishessession/fork, preserves the completeForkSessionResponse, and uses that response’s newly allocated session ID for the command handle. V2Sessionis a cloneable command handle containing only the session ID and connection. It does not own, buffer, or unregister inbound messages.- Register typed
UpdateSessionNotificationandRequestPermissionRequesthandlers onClient.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/updateevents 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_updatedescribes the session-wide foreground state.idlemeans 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_worksends session-widesession/cancel. Cancellation completes after the requiredidleupdate with stop reasoncancelled. 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’sSentRequestis the separate JSON-RPC request-cancellation mechanism.set_config_optionreturns the authoritative replacement option set, andclosereturns 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()andAgent.v2()connections reject ordinary protocol traffic until the initialization response completes;$/cancel_requestremains 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
initializerequest 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
initializerequest 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.rsimplements initialization and the complete baseline session lifecycle.v2_one_shot_client.rsinitializes 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:
- Send
initializeand verify that the agent advertised session support. - Send
session/newand retain the returned command handle and session ID. - Send
session/promptand await its response. This only confirms acceptance. - Ignore queued updates for that new session until its foreground state becomes
running. The running update may already be queued when prompt acceptance arrives. - Project subsequent message updates, and treat the next matching
state_updatewithidleas completion of foreground work. An idle update queued before running is only the session’s earlier ready state. Background updates may still arrive afterward. - Send
session/closewhen 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/newsession/listsession/resumesession/closesession/promptsession/cancelsession/updatenotifications 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:
-
Protocol Layer: JSON-RPC semantics
- Request ID assignment
- Request/response correlation
- Method dispatch to handlers
- Error handling
-
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:
TransportFramecarries one raw message, a structurally non-empty batch, or a malformed wire value retained for a relay - In-process API:
Channel::rxandChannel::txcarryTransportFramedirectly, 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
OutgoingMessageto protocol-levelRawJsonRpcMessage
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
UntypedMessagefor 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 Requestresponse
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
RawJsonRpcMessageor one non-empty batch to JSON - Write newline-delimited JSON to stream
For in-process channels:
- Directly forward
TransportFrameto 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
RawJsonRpcMessageentry 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
TransportFramefrom 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 Requesterror 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 anInternal Errorfor 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 Requestresponse; 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
TransportFramevalues through an in-componentChannel. A caller usingConnectTo::into_channel_and_futuremust poll the returned future. - Exchange newline-delimited JSON through
Lines, using afutures::Sink<String>andfutures::Stream<Item = std::io::Result<String>>. - Use
ByteStreamswithfutures::io::AsyncReadandAsyncWrite.
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
Related Documentation
- Core Library Design - High-level crate architecture
- Conductor Design - How the conductor uses transports
HTTP / WebSocket Transport
agent-client-protocol-http exposes ACP agents over one /acp endpoint.
POST /acpwithinitializecreates a connection and returnsAcp-Connection-Id.- Later
POST /acprequests includeAcp-Connection-Id; session-scoped requests also includeAcp-Session-Idorparams.sessionId. GET /acpwithAccept: text/event-streamstreams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages.GET /acpwith a WebSocket upgrade uses text frames for JSON-RPC messages.DELETE /acptears 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 /acpAccept: text/event-streamAcp-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 /acpAccept: text/event-streamAcp-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/successorto 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::Acpwith aserverId. - Connections use
mcp/connect,mcp/message, andmcp/disconnect. mcp/disconnectis 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.acptotrue. - v2 adds the
capabilities.session.mcp.acpmarker.
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:
- Creates or reuses a connection-scoped localhost bridge endpoint for the
serverIdand replaces the declaration with the HTTP transport for the final agent. - Retains the native
serverIdso connections can be routed back to the component that provided the server. - Opens the endpoint’s native connection by sending
mcp/connectwith that server ID toward the provider. - Relays requests and notifications through
mcp/message, using the returnedconnectionIdfor that active MCP connection. - Sends an
mcp/disconnectrequest 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:
helpreturns the supported commands and scenarios.echo <message>streams<message>back.wait_for_cancelaccepts the prompt and waits forsession/cancel.session_updatesemits every stablesession/updatevariant.contentemits prompt/content-focused updates, including every stableContentBlockvariant.tool_callsemits tool call create and update flows.callbackssends every stable agent-to-client request.elicitationssends only elicitation requests.cancel_statusreports whethersession/cancelhas been received.fullruns 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:
session/promptreturns an empty acceptance response.- Testy independently sends the accepted user message and a
runningstate update. - Output arrives through message updates.
- An
idlestate 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/successoris unwrapped and logged as its inner ACP method.mcp/messageis unwrapped and its inner method is marked with protocolmcp.- 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:
| Crate | Coordinated release |
|---|---|
agent-client-protocol | 2.x |
agent-client-protocol-derive | 2.x |
agent-client-protocol-conductor | 2.x |
agent-client-protocol-cookbook | 2.x |
agent-client-protocol-http | 2.x |
agent-client-protocol-polyfill | 2.x |
agent-client-protocol-trace-viewer | 2.x |
agent-client-protocol-rmcp | 3.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.x | 2.0 |
|---|---|
respond_with_result | route_with_result |
respond | route |
respond_with_error | route_with_error |
respond_with_internal_error | route_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.x | 2.0 |
|---|---|
Builder::with_responder | Builder::with_runner |
The conductor crate applies the same terminology to its public background task:
| 1.x | 2.0 |
|---|---|
agent_client_protocol_conductor::ConductorResponder | agent_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.x | 2.0 |
|---|---|
MatchDispatch::if_message | MatchDispatch::if_dispatch |
MatchDispatchFrom::if_message_from | MatchDispatchFrom::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/connectwithserverId, returning a distinctconnectionId;mcp/messagerequests and notifications keyed by that connection ID; and- a request/response
mcp/disconnectexchange.
The low-level SDK-local McpConnectRequest, McpConnectResponse, McpOverAcpMessage, and
McpDisconnectNotification types were removed. Use the feature-gated schema types instead:
| 1.x SDK-local type | 2.0 schema type |
|---|---|
McpConnectRequest | schema::v1::ConnectMcpRequest |
McpConnectResponse | schema::v1::ConnectMcpResponse |
McpOverAcpMessage request | schema::v1::MessageMcpRequest |
McpOverAcpMessage notification | schema::v1::MessageMcpNotification |
McpDisconnectNotification | schema::v1::DisconnectMcpRequest and DisconnectMcpResponse |
The public method-name constants moved to the schema’s generated method-name tables:
| 1.x SDK-local constant | 2.0 schema constant |
|---|---|
METHOD_MCP_CONNECT_REQUEST | schema::v1::CLIENT_METHOD_NAMES.mcp_connect |
METHOD_MCP_MESSAGE | schema::v1::CLIENT_METHOD_NAMES.mcp_message or AGENT_METHOD_NAMES.mcp_message, depending on direction |
METHOD_MCP_DISCONNECT_NOTIFICATION | schema::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
StringorPathBuffields are semantic newtypes, includingAbsolutePath,MediaType, session/message/tool/terminal IDs, and list cursors. Construct them with.into()or theirnewmethods, and useAsRef<Path>oras_ref()when borrowing their contents. v2::DiffPatch.diffis nowtext.DiffPatch::new(text)remains the preferred constructor.- Terminal state is represented by
Terminal,TerminalUpdate,TerminalOutput,TerminalOutputChunk, andTerminalExitStatus.SessionUpdatealso has terminal update and output-chunk variants, so exhaustive matches must handle the new variants. - The experimental
v2::conversionmodule 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-protocol0.10.x - new API = the planned
agent-client-protocol0.11API
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| { ... })
- becomes
AgentSideConnection::new(handler, outgoing, incoming, spawn)- becomes
Agent.builder().connect_to(ByteStreams::new(outgoing, incoming)).await?
- becomes
- custom
spawnfunction +handle_iofuture- 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 style | New 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 aSentRequest, 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. Themain_fnclosure passed toconnect_with(...)and tasks spawned viacx.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?insideon_receive_*callbacks. Those callbacks run on the dispatch loop, so blocking them deadlocks the connection. Preferon_receiving_result(...),on_receiving_ok_result(...),on_session_start(...), orcx.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 pattern | New 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 PromptRequest | let ActiveSession manage the session lifecycle |
subscribe() to observe streamed session output | ActiveSession::read_update() or ActiveSession::read_to_string() |
intercept and rewrite a session/new request in a proxy | build_session_from(request) |
Also note:
- use
start_session()when you want anActiveSession<'static, _>you can keep around - use
on_session_start(...)insideon_receive_*callbacks when you need to start a session without manually blocking the current task - use
on_proxy_session_start(...)orstart_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 derivedJsonRpcRequest/JsonRpcNotificationtypes, or a catch-allon_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_sessionsand other unstable session methods -> request handlers for the corresponding schema typeext_method/ext_notification-> your own derivedJsonRpcRequest/JsonRpcNotificationtypes, or a catch-allon_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()orActiveSession::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(...)plussend_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::LocalSettokio::task::spawn_local(...)- the custom
spawnclosure passed into connection construction - the separate
handle_iofuture 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
ClientandAgentare 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?oron_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 aroundActiveSession, 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 andMcpServer::Httpvalues using anacp:URL were retired. Current opt-in implementations useMcpServer::Acpwithmcp/connect,mcp/message, andmcp/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:
- Injects Sparkle MCP server into the agent’s tool list during
initialize - Intercepts the first
promptand prepends Sparkle embodiment sequence - 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:
- Process Management - Creates and manages component processes based on command-line configuration
- Message Routing - Routes messages between editor, components, and agent through the proxy chain
- 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 $portprocesses 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:
- Conductor offers proxy capability - When initializing non-last components (proxies), the conductor includes
"proxy": truein the_metafield of the InitializeRequest - Component accepts proxy capability - The component must respond with
"proxy": truein the_metafield of its InitializeResponse - 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:
- Editor sends ACP
promptrequest to orchestrator - Orchestrator forwards to Proxy1 as normal ACP message
- Proxy1 transforms and sends
_proxy/successor/send/request { message: <modified_prompt> } - Orchestrator routes that to Proxy2 as normal ACP
prompt - Eventually reaches agent, response flows back through chain
- 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:
- Binds a fresh TCP port (e.g.,
localhost:54321) - Transforms the MCP server declaration to use
conductor mcp $portas the command - Spawns
conductor mcp $portwhich connects back via TCP and bridges to ACP messages - Always advertises
mcpCapabilities.acp: trueto intermediate components
- Binds a fresh TCP port (e.g.,
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.urlto 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:
-
Agent mode:
conductor agent [proxies...] agent- Manages P/ACP proxy chain
- Routes ACP messages
-
MCP mode:
conductor mcp $port- Acts as MCP server over stdio
- Connects to
localhost:$portvia 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/logmessages for structured logging - Metrics:
_proxy/metricmessages for tracking usage - Configuration:
_proxy/configmessages 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 - ✅
scpRust 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 $portCLI 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.acpcapability - Bind ephemeral TCP ports when bridging needed
- Transform MCP server specs to use
conductor mcp $port - Spawn
conductor mcp $portsubprocess per ACP-transport MCP server - Store mapping:
UUID → TCP port → bridge process - Always advertise
mcpCapabilities.acp: trueto 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.urlmatches 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:
- Conductor orchestrator - Process management, message routing, capability adaptation
- Sparkle P/ACP component - Injects Sparkle MCP server, handles embodiment sequence
- 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:
-
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. -
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:
- Message from Editor → Forward “as is” to first component (same message ID)
_proxy/successor/requestfrom component → Unwrap payload and send to next component (using message ID from the wrapper)- Response from downstream → Send back to whoever made the
_proxyrequest - 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:
-
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" } } } -
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" } } } -
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" } } } } } -
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" } } } -
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" } } } -
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" } } } -
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": truein its response since it was offered the proxy capability. If this field is missing, Conductor will fail initialization with an error. -
Editor → Conductor: session/new (id: U0)
{ "jsonrpc": "2.0", "id": "U0", "method": "session/new", "params": { "tools": { "mcpServers": { "filesystem": { "command": "mcp-filesystem", "args": [] } } } } } -
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": [] } } } } } -
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": [] }
}
}
}
}
}
}
- 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": [] }
}
}
}
}
- 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" }
}
}
- Conductor → Sparkle: _proxy/successor response (id: U1)
{
"jsonrpc": "2.0",
"id": "U1",
"result": {
"sessionId": "S1",
"serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
}
}
- 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:
-
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?" } ] } } -
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?" } ] } } -
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." } ] } } } } -
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." } ] } } -
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": {} } ] } } -
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?" } ] } } } } -
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?" } ] } } -
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:
-
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?" } ] } } -
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?" } ] } } } } -
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?" } ] } } -
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.