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.
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 agent-client-protocol-tokio 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 agent-client-protocol-tokio 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_tokio::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.