| 1 | # Skill: Agent Client Protocol (ACP) — Rust Implementation |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | ACP is a JSON-RPC 2.0 protocol over **stdio** for integrating AI coding agents |
| 6 | with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess; |
| 7 | the editor is the client. Communication is newline-delimited JSON on stdin/stdout. |
| 8 | |
| 9 | Crate: `agent-client-protocol = "0.10.4"` (latest as of 2025) |
| 10 | Docs: https://docs.rs/agent-client-protocol |
| 11 | Spec: https://agentclientprotocol.com |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## Dependency setup |
| 16 | |
| 17 | ```toml |
| 18 | [dependencies] |
| 19 | agent-client-protocol = "0.10.4" |
| 20 | async-trait = "0.1" |
| 21 | tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync"] } |
| 22 | tokio-util = { version = "0.7", features = ["compat"] } |
| 23 | futures = "0.3" |
| 24 | ``` |
| 25 | |
| 26 | --- |
| 27 | |
| 28 | ## The `Agent` trait |
| 29 | |
| 30 | Declared `#[async_trait::async_trait(?Send)]` — futures are `!Send`. |
| 31 | Your impl needs the same annotation: |
| 32 | |
| 33 | ```rust |
| 34 | #[async_trait::async_trait(?Send)] |
| 35 | impl Agent for MyAgent { |
| 36 | async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse> { ... } |
| 37 | async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse> { ... } |
| 38 | async fn new_session(&self, args: NewSessionRequest) -> Result<NewSessionResponse> { ... } |
| 39 | async fn prompt(&self, args: PromptRequest) -> Result<PromptResponse> { ... } |
| 40 | async fn cancel(&self, args: CancelNotification) -> Result<()> { ... } |
| 41 | // All other methods have default impls that return Error::method_not_found() |
| 42 | } |
| 43 | ``` |
| 44 | |
| 45 | You must implement `initialize`, `authenticate`, `new_session`, `prompt`, and `cancel`. |
| 46 | Everything else (`load_session`, `set_session_mode`, etc.) defaults to `Err(method_not_found)`. |
| 47 | |
| 48 | --- |
| 49 | |
| 50 | ## Types and their builders |
| 51 | |
| 52 | All `#[non_exhaustive]` structs require builder methods — struct literal syntax won't compile. |
| 53 | |
| 54 | ### `InitializeRequest` / `InitializeResponse` |
| 55 | |
| 56 | ```rust |
| 57 | // Response builder — use ProtocolVersion::V1, NOT args.protocol_version: |
| 58 | InitializeResponse::new(ProtocolVersion::V1) |
| 59 | .agent_info( |
| 60 | Implementation::new("my-agent", env!("CARGO_PKG_VERSION")) |
| 61 | .title("My Agent"), |
| 62 | ) |
| 63 | .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new( |
| 64 | "my-agent", "My Agent", |
| 65 | ))]) |
| 66 | .agent_capabilities(AgentCapabilities::default()) |
| 67 | ``` |
| 68 | |
| 69 | `auth_methods` must include at least one `AuthMethod::Agent` or Zed hangs on |
| 70 | "Loading…" forever. Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` |
| 71 | from the crate. |
| 72 | |
| 73 | ### `AuthenticateResponse` |
| 74 | |
| 75 | ```rust |
| 76 | Ok(AuthenticateResponse::default()) // No auth = just return default |
| 77 | ``` |
| 78 | |
| 79 | ### `NewSessionResponse` |
| 80 | |
| 81 | ```rust |
| 82 | let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); |
| 83 | Ok(NewSessionResponse::new(session_id)) |
| 84 | ``` |
| 85 | |
| 86 | `SessionId` is a newtype with `Clone`, `PartialEq`, `Display`, `Into<String>`, |
| 87 | and `AsRef<str>`. Store it as-is (not as `String`) so `==` works directly. |
| 88 | |
| 89 | ### `PromptRequest` |
| 90 | |
| 91 | ```rust |
| 92 | args.session_id // type: SessionId |
| 93 | args.prompt // type: Vec<ContentBlock> |
| 94 | ``` |
| 95 | |
| 96 | Extract user text from the prompt: |
| 97 | ```rust |
| 98 | let user_text: String = args.prompt.iter() |
| 99 | .filter_map(|block| match block { |
| 100 | ContentBlock::Text(t) => Some(t.text.as_str()), |
| 101 | _ => None, |
| 102 | }) |
| 103 | .collect::<Vec<_>>() |
| 104 | .join("\n"); |
| 105 | ``` |
| 106 | |
| 107 | ### `PromptResponse` |
| 108 | |
| 109 | ```rust |
| 110 | Ok(PromptResponse::new(StopReason::EndTurn)) |
| 111 | // Other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal |
| 112 | ``` |
| 113 | |
| 114 | ### `ContentBlock` |
| 115 | |
| 116 | ```rust |
| 117 | // Text block — use the From impl: |
| 118 | ContentBlock::from("some text") // impl From<T: Into<String>> for ContentBlock |
| 119 | |
| 120 | // Pattern-match incoming blocks: |
| 121 | match block { |
| 122 | ContentBlock::Text(t) => t.text.as_str(), |
| 123 | ContentBlock::ResourceLink(_) => ..., |
| 124 | ContentBlock::Resource(_) => ..., |
| 125 | _ => ..., // non_exhaustive — always need a wildcard |
| 126 | } |
| 127 | ``` |
| 128 | |
| 129 | ### `ContentChunk` + `SessionUpdate` — streaming |
| 130 | |
| 131 | ```rust |
| 132 | let chunk = ContentChunk::new(ContentBlock::from(delta_text)); |
| 133 | let update = SessionUpdate::AgentMessageChunk(chunk); |
| 134 | // Other variants: UserMessageChunk, AgentThoughtChunk, ToolCall, Plan, ... |
| 135 | ``` |
| 136 | |
| 137 | ### `SessionNotification` — send streaming content to client |
| 138 | |
| 139 | ```rust |
| 140 | let notification = SessionNotification::new(session_id.clone(), update); |
| 141 | // Deliver via AgentSideConnection::session_notification() |
| 142 | ``` |
| 143 | |
| 144 | ### `Error` |
| 145 | |
| 146 | ```rust |
| 147 | // There is NO Error::internal(msg) method — use: |
| 148 | agent_client_protocol::Error::new(-32603, "your message here") |
| 149 | |
| 150 | // For invalid params: |
| 151 | agent_client_protocol::Error::invalid_params() |
| 152 | |
| 153 | // For method not found (already the trait default): |
| 154 | agent_client_protocol::Error::method_not_found() |
| 155 | ``` |
| 156 | |
| 157 | --- |
| 158 | |
| 159 | ## Running the agent — `AgentSideConnection` |
| 160 | |
| 161 | Wraps stdin/stdout with JSON-RPC machinery. |
| 162 | |
| 163 | ```rust |
| 164 | use futures::future::LocalBoxFuture; |
| 165 | use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; |
| 166 | |
| 167 | // Adapt tokio I/O to futures AsyncRead/AsyncWrite (the SDK expects these) |
| 168 | let stdin = tokio::io::stdin().compat(); |
| 169 | let stdout = tokio::io::stdout().compat_write(); |
| 170 | |
| 171 | // Must run inside a LocalSet — the spawn fn takes LocalBoxFuture (!Send) |
| 172 | let local = tokio::task::LocalSet::new(); |
| 173 | local.run_until(async move { |
| 174 | let (conn, io_task) = AgentSideConnection::new( |
| 175 | agent, |
| 176 | stdout, |
| 177 | stdin, |
| 178 | |fut: LocalBoxFuture<'static, ()>| { |
| 179 | tokio::task::spawn_local(fut); // requires LocalSet context |
| 180 | }, |
| 181 | ); |
| 182 | |
| 183 | // ... set up forwarder task using conn ... |
| 184 | |
| 185 | io_task.await // drives JSON-RPC until client disconnects |
| 186 | }).await; |
| 187 | ``` |
| 188 | |
| 189 | `AgentSideConnection::new` returns `(conn, io_task)` — you need both. `io_task` |
| 190 | drives the actual IO; `conn` sends notifications. The spawn closure gets |
| 191 | `LocalBoxFuture<'static, ()>` (not Send), so use `tokio::task::spawn_local`, |
| 192 | not `tokio::spawn`. Everything must sit inside |
| 193 | `tokio::task::LocalSet::new().run_until(...)`. |
| 194 | |
| 195 | --- |
| 196 | |
| 197 | ## Streaming — circular dependency pattern |
| 198 | |
| 199 | `Agent::prompt()` needs to send `SessionNotification` through the connection, |
| 200 | but the connection is built *from* the agent. Break the cycle with an mpsc channel: |
| 201 | |
| 202 | ```rust |
| 203 | // 1. Create channel BEFORE the agent |
| 204 | let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); |
| 205 | |
| 206 | // 2. Pass sender into agent |
| 207 | let agent = MyAgent { notification_tx, ... }; |
| 208 | |
| 209 | // 3. Create connection |
| 210 | let (conn, io_task) = AgentSideConnection::new(agent, stdout, stdin, |fut| { |
| 211 | tokio::task::spawn_local(fut); |
| 212 | }); |
| 213 | |
| 214 | // 4. Spawn forwarder that holds `conn` |
| 215 | tokio::task::spawn_local(async move { |
| 216 | while let Some(notification) = notification_rx.recv().await { |
| 217 | conn.session_notification(notification).await.ok(); |
| 218 | } |
| 219 | }); |
| 220 | |
| 221 | // 5. Run IO |
| 222 | io_task.await; |
| 223 | ``` |
| 224 | |
| 225 | Inside `prompt()`, push chunks through the channel: |
| 226 | ```rust |
| 227 | self.notification_tx.send(SessionNotification::new( |
| 228 | session_id.clone(), |
| 229 | SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(delta))), |
| 230 | )).await.ok(); // ignore send errors (channel closed = client gone) |
| 231 | ``` |
| 232 | |
| 233 | --- |
| 234 | |
| 235 | ## Logging |
| 236 | |
| 237 | Log to **stderr** — stdout is the ACP JSON-RPC wire: |
| 238 | |
| 239 | ```rust |
| 240 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) |
| 241 | .target(env_logger::Target::Stderr) |
| 242 | .init(); |
| 243 | ``` |
| 244 | |
| 245 | --- |
| 246 | |
| 247 | ## Protocol flow |
| 248 | |
| 249 | ``` |
| 250 | Editor Agent |
| 251 | │ │ |
| 252 | │── initialize ────────────────►│ (negotiate version + capabilities) |
| 253 | │◄─ InitializeResponse ─────────│ |
| 254 | │ │ |
| 255 | │── authenticate ──────────────►│ (method_id from authMethods) |
| 256 | │◄─ AuthenticateResponse ───────│ |
| 257 | │ │ |
| 258 | │── session/new ───────────────►│ (create session, load model) |
| 259 | │◄─ NewSessionResponse ─────────│ |
| 260 | │ │ |
| 261 | │── session/prompt ────────────►│ (user message) |
| 262 | │◄─ session/update (N times) ───│ (streaming tokens via notification) |
| 263 | │◄─ PromptResponse ─────────────│ (stop_reason = EndTurn when done) |
| 264 | │ │ |
| 265 | │── session/cancel (optional) ──►│ |
| 266 | │ │ |
| 267 | │── [disconnect] ───────────────►│ (io_task future resolves → shutdown) |
| 268 | ``` |
| 269 | |
| 270 | --- |
| 271 | |
| 272 | ## Zed configuration |
| 273 | |
| 274 | ```json |
| 275 | { |
| 276 | "agent_servers": { |
| 277 | "MyAgent": { |
| 278 | "type": "custom", |
| 279 | "command": "/path/to/binary" |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | ``` |
| 284 | |
| 285 | --- |
| 286 | |
| 287 | ## Gotchas |
| 288 | |
| 289 | 1. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`. |
| 290 | 2. **All protocol structs are `#[non_exhaustive]`** — use builder methods, |
| 291 | never struct literals. Add `_ => ...` wildcards when matching. |
| 292 | 3. **`LocalBoxFuture` is `!Send`** — `tokio::spawn` won't work; use |
| 293 | `tokio::task::spawn_local` inside a `LocalSet`. |
| 294 | 4. **`tokio::task::spawn_local` panics outside a `LocalSet`** — wrap with |
| 295 | `LocalSet::new().run_until(async { ... }).await`. |
| 296 | 5. **Store `SessionId` as `SessionId`**, not `String` — otherwise `==` |
| 297 | comparisons get annoying. |
| 298 | 6. **One session per connection is fine for MVP** — reuse the model with |
| 299 | `clear_history()` instead of reloading. |
| 300 | 7. **`AgentCapabilities::default()` exists** — all capabilities None/false. |
| 301 | 8. **`block_in_place` panics inside `spawn_local`** — dependencies that call |
| 302 | `tokio::task::block_in_place` internally (e.g. `mistralrs`) will blow up |
| 303 | with "can call blocking only when running on the multi-threaded runtime" |
| 304 | from a `spawn_local` task. Fix: do the blocking work *before* entering |
| 305 | the `LocalSet`, while you're still on a normal multi-thread worker, then |
| 306 | pass the result into your agent struct. |
| 307 | 9. **Empty `authMethods` hangs Zed** — `InitializeResponse` with an empty |
| 308 | `auth_methods` vec makes Zed show "Loading…" forever. Always include at |
| 309 | least one `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. |
| 310 | Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` from the crate. |
| 311 | 10. **Never write to stdout except JSON-RPC** — any library that prints to |
| 312 | stdout (`mistralrs` model metadata, stray `println!`, whatever) will |
| 313 | corrupt the wire. Redirect diagnostics to stderr. If a dependency writes |
| 314 | to stdout internally, fix it or suppress it before shipping. |