Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 writingyou implementstart at
a coding agent an editor drivesAgentServing an agent
an editor, TUI or app driving an agentClientDriving an agent
a frontend with an event loop alreadynothingAn 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

cratewhat it isdependencies
cacp-protoACP v1 wire types, method names, the protocol errorserde, serde_json
cacpthe JSON-RPC connection and both rolesthe above, plus tokio
cacp-eventsthe client as a channel instead of a traitcacp with client
cacp-agentsthe registry catalog and an installer for itserde, 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"] }
featuregives youadds
clientClient, AgentConn, spawn, connect, connect_ontokio/process
serverAgent, ClientConn, serve, serve_on, serve_on_stdiotokio/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:

functiontakes
serve_on_stdiothis process’s own stdin and stdout
serveone duplex stream — a socket, an in-memory pipe
serve_ona 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, with StopReason::Cancelled — so keep awaiting the prompt future rather than dropping it.
  • Abandoning one request is what dropping its future does: the peer gets $/cancel_request and 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:

  • _meta is a field on every message that carries it in the spec, read and written untouched
  • _-prefixed methods reach ext_request / ext_notification on 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.