Introduction
ACP is how a code editor and a coding agent talk to each other — JSON-RPC over a pipe. Two roles share one connection, and each can call the other:
- the agent answers
initialize, opens sessions, and runs prompt turns - the client — the editor — receives progress, answers permission prompts, and serves files and terminals on the agent’s behalf
cacp implements both ends. Which one you want decides where to start:
| you are writing | you implement | start at |
|---|---|---|
| a coding agent an editor drives | Agent | Serving an agent |
| an editor, TUI or app driving an agent | Client | Driving an agent |
| a frontend with an event loop already | nothing | An event loop |
Whichever role you serve, only a handful of methods are required. Everything
else answers method not found until you implement it, so a half-built peer is
a working peer — it just advertises less.
Crates
| crate | what it is | dependencies |
|---|---|---|
cacp-proto | ACP v1 wire types, method names, the protocol error | serde, serde_json |
cacp | the JSON-RPC connection and both roles | the above, plus tokio |
cacp-events | the client as a channel instead of a trait | cacp with client |
cacp-agents | the registry catalog and an installer for it | serde, serde_json, anyhow, ureq |
Depend on cacp-proto alone if all you do is read and write ACP JSON — it is
data only, and pulls in no async runtime.
cacp = "0.0.1"
Each role is a feature and both are on by default. Turn off the one you do not implement:
cacp = { version = "0.0.1", default-features = false, features = ["client"] }
| feature | gives you | adds |
|---|---|---|
client | Client, AgentConn, spawn, connect, connect_on | tokio/process |
server | Agent, ClientConn, serve, serve_on, serve_on_stdio | tokio/io-std |
cacp-events and cacp-agents are layers, not dependencies of the core: each
reaches only what cacp makes public, which is why each is its own crate rather
than a feature.
Serving an agent
Implement Agent and hand it to one of the serve_* functions. Only
initialize, new_session and prompt are required — advertise whatever else
you implement in InitializeResponse::agent_capabilities.
use cacp::{Agent, Result, schema};
use std::sync::Arc;
struct Echo;
impl Agent for Echo {
async fn initialize(&self, _: schema::InitializeRequest) -> Result<schema::InitializeResponse> {
Ok(schema::InitializeResponse {
protocol_version: schema::ProtocolVersion::LATEST,
agent_capabilities: Default::default(),
auth_methods: Vec::new(),
agent_info: None,
meta: None,
})
}
async fn new_session(
&self,
_: schema::NewSessionRequest,
) -> Result<schema::NewSessionResponse> {
Ok(schema::NewSessionResponse {
session_id: "session-1".into(),
modes: None,
config_options: None,
meta: None,
})
}
async fn prompt(&self, _: schema::PromptRequest) -> Result<schema::PromptResponse> {
Ok(schema::PromptResponse::new(schema::StopReason::EndTurn))
}
}
#[tokio::main]
async fn main() {
let _client = cacp::serve_on_stdio(Arc::new(Echo));
// The read and write loops run on the runtime; keep the process alive.
std::future::pending::<()>().await
}
That is crates/cacp/examples/agent.rs, included verbatim — cargo test
compiles it, so this page cannot drift from working code.
serve_on_stdio hands back a ClientConn for calling the editor — reporting
progress with session_update, asking permission, reading files through it.
Three ways in, depending on what you already hold:
| function | takes |
|---|---|
serve_on_stdio | this process’s own stdin and stdout |
serve | one duplex stream — a socket, an in-memory pipe |
serve_on | a reader and a writer owned separately |
Driving an agent
Implement Client and hand it to spawn. Only session_update and
request_permission are required; fs/*, terminal/* and elicitation/*
decline until you implement them.
use cacp::{Client, Result, schema};
use std::sync::Arc;
use tokio::process::Command;
struct Ui;
impl Client for Ui {
async fn session_update(&self, notification: schema::SessionNotification) {
if let schema::SessionUpdate::AgentMessageChunk(chunk) = notification.update {
print!("{:?}", chunk.content);
}
}
async fn request_permission(
&self,
request: schema::RequestPermissionRequest,
) -> Result<schema::RequestPermissionResponse> {
Ok(schema::RequestPermissionResponse::selected(
request.options[0].option_id.clone(),
))
}
}
#[tokio::main]
async fn main() -> Result<()> {
let (agent, _child) = cacp::spawn(Command::new("my-agent").arg("--acp"), Arc::new(Ui))?;
agent
.initialize(schema::InitializeRequest::new(Default::default()))
.await?;
let session = agent
.new_session(schema::NewSessionRequest::new("/path/to/repo"))
.await?;
let done = agent
.prompt(schema::PromptRequest::new(
session.session_id,
vec!["explain this repo".into()],
))
.await?;
println!("{:?}", done.stop_reason);
Ok(())
}
That is crates/cacp/examples/client.rs, included verbatim — cargo test
compiles it, so this page cannot drift from working code.
spawn runs the agent as a subprocess and takes its stdin and stdout. stderr is
left as you configured it, since a TUI usually wants it captured and a CLI
usually does not. The agent is killed when the returned Child drops.
connect and connect_on are the same thing over a stream you already hold.
Cancelling
Two different cancellations, easy to confuse:
- Ending a turn is
session/cancel. The agent still answers the prompt, withStopReason::Cancelled— so keep awaiting the prompt future rather than dropping it. - Abandoning one request is what dropping its future does: the peer gets
$/cancel_requestand stops working on something nobody is waiting for.
An event loop instead of a trait
A frontend has a loop already, so cacp-events hands it one rather than a trait
to implement. Every call from the agent arrives as an Event on a channel.
let (client, mut events) = cacp_events::channel();
let (agent, _child) = cacp::spawn(&mut Command::new("my-agent"), client)?;
while let Some(event) = events.recv().await {
match event {
Event::Update(notification) => draw(notification.update),
Event::Permission(request, reply) => reply.send(ask_the_user(request).await),
_ => {}
}
}
A variant carrying a Reply is a request: answer it, or drop the reply to
decline. Dropping sends method not found — exactly what the Client trait
sends for a method you never implemented — so the _ => {} above serves nothing
but updates and permission, and says so on the wire.
That transparency is the whole design. A consumer that ignores an Event is
indistinguishable from one that never implemented that method, which is why the
adapter needs no builder and no opt-in list.
What it inherits
Notifications reach the channel in wire order. A request is dispatched on its own task, so it can arrive just after a notification that followed it on the wire.
A Reply also knows when the agent gave up: is_cancelled() and
cancelled().await fire when $/cancel_request aborts the request, which is
the cue to take a permission prompt back off the screen.
When to implement Client directly instead
The channel is not always the shorter path. Answer permission by policy rather
than by asking a human — auto-approve, or refuse everything — and routing it out
to a loop and back is strictly more code than a two-method impl Client.
Finding an agent to drive
The protocol publishes a catalog of ACP agents pinned to exact versions.
cacp-agents reads it and installs from it, so an agent’s build never changes
underfoot the way npx <pkg>@latest does, and no package manager sits in the
chat path.
let catalog = registry::catalog(&cache_dir).expect("a catalog");
let agent = catalog.agents.iter().find(|a| a.id == "claude-acp").unwrap();
let installed = match Installed::find(&data_dir, &agent.id) {
Some(installed) => installed,
None => agent.install(&data_dir, |line| println!("{line}"))?,
};
let mut command = Command::new(&installed.command);
command.args(&installed.args).current_dir(&cwd);
let (conn, _child) = cacp::spawn(&mut command, client)?;
It carries no runtime and does not depend on cacp. The working directory, the
environment and stderr are yours to set, and cacp::spawn takes it from there.
registry::catalog serves a cache under a day old as-is, tries the network
otherwise, and falls back to a stale cache when that fails — so a catalog opened
offline is out of date rather than empty.
Everything here blocks: it reaches the network and runs npm. Call it off a
worker rather than inside a turn.
MCP servers
The same module shape covers the MCP registry, for handing an agent servers it
can reach: mcp::search queries it live, and Server::install places an npm
package or hands back None for a remote server that needs no install.
Remote servers only work against an agent that advertises
mcp_capabilities.http. Check before offering one, rather than sending an entry
it will fail to dial.
Coverage
Every v1 method name, on both sides. Beyond the stable core, all eight areas the spec still marks unstable are implemented: session fork, LLM providers, plan operations, next edit suggestions, end-of-turn token usage, tool call names, auth methods, and MCP over ACP.
Extension
Both mechanisms the spec defines work:
_metais a field on every message that carries it in the spec, read and written untouched_-prefixed methods reachext_request/ext_notificationon either role, which decline by default like every other optional method
Unknown shapes
Update kinds, content blocks, tool call content, plan payloads and enum values
this revision does not know arrive in an Other variant and round-trip whole,
rather than failing the message they came in. A newer peer does not break you.
Not there yet
The leniency upstream applies field by field, where a malformed optional field
falls back to its default and a bad array item is skipped rather than failing
the message around it. Unknown shapes are handled — that is what the Other
variants are for — but malformed ones are still an error.