Elevator pitch
What are you proposing to change?Replace the current ACP Rust SDK with a new implementation based on SACP (Symposium ACP). The new SDK provides a component-based architecture with builder patterns, explicit message ordering guarantees, and first-class support for Proxy Chains and MCP-over-ACP.
Status quo
How do things work today and what problems does this cause? Why would we change things?The current
agent-client-protocol crate has a straightforward design with trait-based callbacks for common ACP methods and well-typed requests and responses. It’s convenient for simple purposes but quickly becomes awkward when attempting more complex designs.
Two examples that we found pushed the limits of the design are the conductor (from the proxy chains RFD) and the patchwork-rs project:
The Conductor is an orchestrator that routes messages between proxies, agents, and MCP servers. It must adapt messages as they flow through the system and maintain the correct ordering.
Patchwork is a programmatic interface for working with agents. It allows Rust programs to run prompts that provide custom tools (implemented using MCP-over-ACP) and messages:
Limitation: Handlers can’t send messages
The current SDK uses traits likeAgent where handler methods receive only the request and return only the response:
SessionNotification while processing a prompt, you need to obtain an AgentSideConnection through some other means and coordinate access yourself.
This is awkward for agents (which want to stream progress during prompt processing) and prohibitive for proxies (which need to forward messages to their successor while handling a request from their predecessor).
Goal: Handlers should receive a context parameter that provides methods to send requests and notifications back through the connection.
Limitation: Fixed set of handlers
ACP is an extensible protocol where users can add their own method names beginning with_. The current SDK uses a trait which means that it cannot offer “first-class” support for user-defined requests/notifications. Instead, these are handled using extension methods (ext_method, ext_notification). These methods have no static typing and require the user to work with raw JSON.
Goal: Allow SDK users to define their own request/notification types that are handled in the same fashion as built-in types.
Limitation: Message handlers must be the same across all session
The current API always executes the same handler code for a particular method (e.g., a session/update). If different handling is required for a particular session, that handler must maintain some kind of map from session-id to identify what handling is required, which is non-trivial bookkeeping that can become awkward. As an example of how complex this can get, consider the elaborate message forwarding scheme used by older versions of patchwork. Goal: Allow SDK users to add/remove “dynamic” handlers that are specific to a particular session or other part of the protocol. These handlers should be closures so they can capture state.Limitation: No ordering guarantees
In the current SDK, every incoming request or notification results is handled in a freshly spawned task. This means that it is not possible to guarantee that requests or notifications are handled in the order they arrive. It is also not possible to be sure that a notification is fully handled before the response to another request; this makes it difficult to, for example, be sure that asession/update notification is handled before the turn is ended (which is sent as the response to the prompt request). This is essential for an application like patchwork, which wishes to fully capture the updates before returning.
Goal: Handlers should block message processing to allow them to ensure that they fully process a message before other messages are processed.
Limitation: Confusing naming and 1:1 assumption
AgentSideConnection is ambiguous - does this represent the agent, or the connection to an agent? What’s more, the SDK currently assumes that each connection has a single peer, but proxies may wish to send/receive messages from multiple peers (client or agent). This was a constant source of confusion in early versions of the conductor and frequently required the author to get out a pencil and paper and work things out very carefully.
Goal: Use directional naming like ClientToAgent that makes relationships explicit: “I am the client, the remote peer is an agent.” Enable multiple peers.
Limitation: Awkward to connect components
When building tests and other applications, it’s convenient to be able to create a client and connect it directly to an agent, leaving the plumbing to the framework. The current SDK only accepts channels and byte-streams which creates unnecessary boilerplate. Goal: Provide aComponent trait that abstracts over anything that can connect to an ACP transport, enabling uniform handling in orchestration scenarios.
Challenge: Executor independence and starvation freedom
This isn’t a limitation of the current SDK per se, but a common pitfall in async Rust designs that we want to address. We want the SDK to be independent from specific executors (not tied to tokio) while still supporting richer patterns like spawning background tasks. One specific common issue with Rust async APIs is starvation, which can occur with stream-like APIs where it is important to keep awaiting the stream so that items make progress. For example, in a setup like this one, the “connection” is not being “awaited” while each message is handled:for_each or run_until:
What we propose to do about it
What are you proposing to improve the situation?We propose to adopt the design and implementation from
sacp (developed as part of the Symposium project) as the foundation for agent-client-protocol v1.0. The sacp crates will be imported into this repository and renamed:
| Current | New name |
|---|---|
sacp | agent-client-protocol (v1.0) |
sacp-derive | agent-client-protocol-derive |
sacp-tokio | agent-client-protocol-tokio |
sacp-rmcp | agent-client-protocol-rmcp |
sacp-conductor | agent-client-protocol-conductor |
sacp-test | agent-client-protocol-test |
sacp-tee | agent-client-protocol-tee |
sacp-trace-viewer | agent-client-protocol-trace-viewer |
sacp crates will then be deprecated in favor of the agent-client-protocol family. The new SDK addresses the limitations above through a builder-based API with explicit connection semantics.
The following table summarizes the key API concepts and which goals they address:
| API Concept | Goals Addressed |
|---|---|
Link types (ClientToAgent, AgentToClient) | Confusing naming, 1:1 assumption |
Component trait + connect_to | Awkward to connect components |
Connection context (cx) | Handlers can’t send messages |
on_receive_* handlers with closure types | Fixed set of handlers |
serve / run_until | Executor independence, starvation freedom |
| Session builders + dynamic handlers | Handlers must be same across sessions |
Ordering guarantees + spawn | No ordering guarantees |
- sacp-conductor (to be renamed agent-client-protocol-conductor) - implementation of the conductor from the proxy chains RFD
- Patchwork - programmatic agent orchestration
- elizacp - agent implementing the classic ELIZA program
- agent-client-protocol-tee - proxy that logs messages before forwarding
- yopo (“You Only Prompt Once”) - CLI tool for single prompts
Deep dive
This section walks through the SDK concepts in detail, organized by what you’re trying to do.Getting up and going
Link types and directional naming
The SDK is organized around link types that describe who you are and who you’re talking to. The two most common examples are:ClientToAgent- “I am a client, connecting to an agent”AgentToClient- “I am an agent, serving a client”
builder method. Builders use the typical “fluent” style:
ClientToAgent and AgentToClient generally error on unhandled messages, but proxies default to forwarding.)
The Component trait and connect_to
The connect_to method connects your builder to the other side. The argument can be anything that implements the Component<Link> trait, which abstracts over anything that can communicate via JSON-RPC:
AcpAgent type allows connecting to an external ACP agent or agent extension:
Component:
Running connections: serve and run_until
The connect_to method returns a JrConnection, but that connection is inert until executed. There are two ways to run it.
serve() runs until the connection is closed. This is for “reactive” components that respond to incoming messages:
run_until() takes an async closure and runs your code concurrently with message handling. The closure receives a connection context (conventionally called cx) - this is how you interact with the connection, sending messages, spawning tasks, and adding dynamic handlers. When the closure returns, the connection closes:
run_until pattern directly addresses starvation. Instead of exposing an async stream that users might accidentally block, run_until runs your code concurrently with message handling.
The cx type (JrConnectionCx) follows the “handle” pattern: cloned values refer to the same connection. It’s 'static so it can be sent across threads or stored in structs. Handlers registered with on_receive_* also receive a cx parameter.
Sending messages
Sending notifications
Usecx.send_notification() to send a notification. It returns a Result that is Err if the connection is broken:
Sending requests
Usecx.send_request() to send a request. This returns a handle for managing the response:
on_response / on_ok_response registers a handler that runs when the response arrives:
block_task returns a future you can await:
block_task approach is convenient but dangerous in handlers (methods that begin with on_). See Controlling ordering for details.
Controlling ordering
Atomic handlers
Handler methods (methods whose names begin withon_) execute in the order messages arrive. Each handler must complete before the next message is processed:
block_task and deadlock
Using block_task inside a handler creates a deadlock: the handler waits for a response, but responses can’t be processed until the handler completes.
on_response instead, or spawn a task.
Spawning tasks
Usecx.spawn to run work concurrently with message handling:
JrConnectionCx and don’t require runtime-specific spawning.
Client sessions
When a client sends aNewSessionRequest, agents typically need to set up session-specific state: handlers that only apply to this session, MCP servers with tools tailored to the workspace, or initialization logic that runs once the session is confirmed.
Session builders
The session builder API provides a fluent interface for configuring sessions. Start withcx.build_session() or cx.build_session_from() and chain configuration methods:
Running sessions with run_until
The primary way to run a session is with block_task().run_until(). This pattern allows your closure to capture borrowed state from the surrounding scope - no 'static requirement:
run_until closure receives an ActiveSession with methods for interacting with the agent:
send_prompt(text)- Send a prompt to the agentread_to_string()- Read all updates until the turn ends, returning text contentread_update()- Read individual updates for fine-grained control
agent-client-protocol-rmcp crate:
Non-blocking sessions with on_session_start
When you need to start a session from inside an on_receive_* handler but can’t block, use on_session_start. This spawns the session work and returns immediately:
on_session_start requires 'static - closures and MCP servers cannot borrow from the surrounding scope. Use owned data or Arc for shared state.
start_session and proxy sessions
For cases where you want to avoid the rightward drift of run_until but still need blocking behavior, start_session returns an ActiveSession handle directly:
on_session_start, this requires 'static for closures and MCP servers.
For proxies that want to inject MCP servers but otherwise forward all messages, use start_session_proxy:
Handling messages
Notification handlers
Register handlers usingon_receive_notification. The closure’s first argument type determines which notification type it handles:
Request handlers
Request handlers receive an additionalrequest_cx parameter for sending the response:
request_cx is #[must_use] - the compiler warns if you forget to send a response. It provides three methods:
respond(response)- Send a successful responserespond_with_error(error)- Send an error responserespond_with_result(result)- Send either, based on aResult
request_cx is Send, so you can move it to another task or thread if you need to respond asynchronously:
Custom message types
Define custom notifications and requests using derive macros:ext_notification path required:
Generic message handlers
Useon_receive_message with MessageCx to intercept any incoming message (request or notification) before typed handlers:
MessageCx is useful for forwarding, logging, or other scenarios where you need to intercept messages before typed dispatch.
Message handling in depth
Handler chains
A message handler takes ownership of a message and either handles it or returns a (possibly modified) copy to be tried by the next handler. Handlers are chained together - each gets a chance to claim the message before it passes to the next. Static handlers are registered at build time via.on_receive_request(), etc. They’re tried in registration order.
Dynamic handlers are added at runtime via cx.add_dynamic_handler(). They’re useful for sub-protocols where groups of related messages are identified by some kind of ID. For example, session messages all share a session_id - a dynamic handler can be registered for each session to handle its messages.
Link default handler provides fallback behavior based on the link type (e.g., proxies forward unhandled messages).
Message handlers
TheJrMessageHandler trait defines how handlers work:
Handled::Yes. If not, it returns ownership via Handled::No { message, retry } so the next handler can try:
retry flag: If any of the static or dynamic handlers returns retry: true, and no handler ultimately claims the message, it gets queued and offered to each new dynamic handler as it’s added. This solves a race condition with sessions: messages for a session may arrive before the session’s dynamic handler is registered.
The default handlers for ClientToAgent and AgentToClient already set retry: true for session messages with unrecognized session IDs, so you typically don’t need to handle this yourself.
For convenience, handlers can return Ok(()) which is equivalent to Handled::Yes.
Handlers can also modify the message before passing it along:
Registering dynamic handlers
Register dynamic handlers at runtime for session-specific or protocol-specific message handling:registration is dropped, the dynamic handler is removed. To keep it alive indefinitely, call run_indefinitely():
Default handling from link type
Each link type defines default handling for unhandled messages. For example:ClientToAgent- Errors on unhandled requests, ignores unhandled notificationsProxyToConductor- Forwards unhandled messages to the next component
MatchMessage for implementing handlers
When implementingJrMessageHandler directly, MatchMessage provides ergonomic dispatch:
MatchMessageFrom dispatches based on message source:
Error handling
Errors in handlers tear down the connection. If a handler returns anErr, the connection closes and all pending operations fail.
For request handlers, you can propagate error responses instead of tearing down the connection:
Writing proxies
Multiple peers
Simple link types likeClientToAgent have one remote peer. Proxy link types like ProxyToConductor have two: Client (predecessor) and Agent (successor).
With multiple peers, you must explicitly name which peer you’re communicating with:
Default forwarding
For proxies, the default handling is typicallyForward - unhandled messages pass through to the next component. You only need to register handlers for messages you want to intercept or modify.
Session builders for proxies
Proxies can add session-scoped handlers:Advanced: Defining custom link types
Link types define the relationship between peers. The SDK provides built-in types, but you can define your own:Shiny future
How will things play out once this feature exists?
Unified Rust ACP experience
The Rust ecosystem will have a single SDK for ACP development. Whether you’re building a simple client, a proxy chain, or a programmatic orchestration framework like patchwork-rs, the same SDK handles all cases.Smooth transition from the current SDK
The new SDK is more ergonomic than the current trait-based approach, so migration should be straightforward for most users. The builder pattern with context parameters (cx) replaces the AgentSideConnection pattern, and the directional naming (ClientToAgent vs AgentSideConnection) makes code clearer.
We can provide migration guidance and potentially a thin compatibility layer for common patterns, but most users will find the new code simpler than the old.
Potential crate reorganization
Currently,agent-client-protocol contains both generic JSON-RPC machinery (builder patterns, message handling, connection management) and ACP-specific types (link types, schema integration). In the future, it might be valuable to split the generic JSON-RPC layer into its own crate.
However, this is complicated by the trait implementations: the generic traits need to be implemented for ACP types currently defined in the schema crate. We’d need to carefully consider where type definitions live to avoid orphan rule issues. This reorganization isn’t blocking for the initial adoption.
Cross-language SDK alignment
The design principles here - builder patterns, context parameters, directional naming, component abstractions - aren’t Rust-specific. They represent good SDK design that could inform TypeScript and other language SDKs. This doesn’t mean other SDKs should be ports of the Rust SDK. Each language has its own idioms. But the core ideas (explicit message ordering, composable components, context in callbacks) translate across languages.Foundation for protocol evolution
The builder pattern and component model make it easier to evolve the ACP protocol. New methods can be added without breaking existing code. New component types (beyond client/agent/proxy) can be introduced by implementing the Component trait.Implementation details and plan
Tell me more about your implementation. What is your detailed implementation plan?
Crate structure
The new SDK is organized into several crates with clear responsibilities:agent-client-protocol- Core SDK with builder patterns, link types, and component abstractionsagent-client-protocol-tokio- Tokio runtime integration (spawn, timers, I/O)agent-client-protocol-rmcp- Bridge to the rmcp crate for MCP integrationagent-client-protocol-conductor- Reference conductor implementationagent-client-protocol-derive- Derive macros for JSON-RPC traitsagent-client-protocol-test- Test utilities and mock implementationsagent-client-protocol-tee- Debugging proxy that logs all trafficagent-client-protocol-trace-viewer- Interactive sequence diagram viewer for trace files
Current status
A working implementation exists in the symposium-dev/symposium-acp repository and is published on crates.io. It powers:- The conductor (proxy chain orchestration)
- patchwork-rs (programmatic agent orchestration)
- Symposium (Rust development environment)
Migration path
The transition involves importing thesacp implementation into this repository:
- Import
sacpcrates into this repository with the newagent-client-protocol-*naming - Release
agent-client-protocolv1.0 with the new builder-based API - Deprecate
sacpcrates on crates.io, pointing users to theagent-client-protocolfamily - Provide migration guidance for users of the current v0.x SDK
Frequently asked questions
What questions have arisen over the course of authoring this document or during subsequent discussions?
What alternative approaches did you consider?
We first attempted to build on the existing SDK but due to the limitations decided to try an alternative approach.What about other language SDKs?
We would like to try and adapt these ideas to other languages. It would be good if the SDKs for all languages took the same general approach. Most of the concerns in this document are not Rust-specific, though as often happens, the limitations become more annoying in Rust because of the limits imposed by the ownership system.How does this relate to the Proxy Chains and MCP-over-ACP RFDs?
This expanded SDK design is motivated by working through the use cases enabled by proxy chains and MCP-over-ACP.How well-tested is this design?
The design has been used for a wide range of projects but the majority were written by the SDK author, though Amazon’s kiro-cli team and the Goose client adopted sacp for their use case with minimal difficulty. Before we finalize the design, it would be good to have more adopters to help ensure that it meets all common needs.Can I derive JrRequest/JrNotification on enums?
Not currently. The derive macros only support structs with a single method name. For enums that group related messages (e.g., all session-related requests), you would need to implement the traits manually.
This is a potential future enhancement - enum derives could dispatch to different methods per variant, which would be useful for MessageCx<Req, Notif> typed handlers. For now, use the untyped MessageCx with MatchMessage for this pattern.
What changes are needed before stabilizing?
We are in the process of changing how response messages work to simplify the implementation of the conductor. Before stabilizing we should do a thorough review of the methods and look for candidates that can be removed or simplified. The conductor is feature complete but the support for MCP-over-ACP needs a few minor improvements (in particular, it should detect when the agent only supports stdio bridging and not attempt to use HTTP, which it currently does not).Revision history
- Initial draft based on working implementation in symposium-acp repository.