When I started exploring the Agent Client Protocol for a project where I wanted to build a coding assistant that worked natively in Zed and other ACP-compatible editors, I quickly noticed that the ecosystem is TypeScript/Node.js-first. The official ACP reference implementations β from Codex CLI, Gemini CLI, Cline, and others β are mostly Node.js or Python.
As of early 2026, there is no official Rust SDK for ACP. This article is about implementing it from the protocol specification directly.
The good news: ACP is a clean JSON-RPC 2.0 protocol over stdio. If you've read the spec, the wire format is straightforward. And Rust has everything we need to implement it properly: async I/O, excellent JSON support, and precise control over stdio framing.
This article walks through building a minimal but complete ACP-compliant agent in Rust β one that can negotiate capabilities with an ACP client (like Zed), create sessions, receive prompt turns, and stream back responses.
ACP Wire Protocol Recap
Before writing code, it's worth being precise about the protocol. Everything is newline-delimited JSON-RPC 2.0 over stdin/stdout.
Methods the Agent must implement (baseline)
Method
Direction
Description
initialize
Client β Agent
Version negotiation and capability exchange
session/new
Client β Agent
Create a new conversation session
session/prompt
Client β Agent
Send a user message; agent processes and responds
Notifications the Agent sends
Notification
Direction
Description
session/update
Agent β Client
Streaming output: text chunks, tool calls, diffs, thoughts
Methods the Agent can call on the Client
Method
Direction
Description
session/request_permission
Agent β Client
Ask user to approve a tool call
The key distinction from MCP: ACP is bidirectional. The agent makes requests of the editor (for file system access, terminal, permission gating), not just the other way around.
What We're Building
A minimal but spec-compliant ACP agent that:
Handles initialize β capability negotiation
Handles session/new β creates a session with a unique ID
Handles session/prompt β receives a user message, sends back streaming session/update notifications, and returns a final response
Runs over stdio
The actual AI logic is a placeholder β the focus here is the correct ACP framing and protocol flow.
Project Setup
No ACP SDK needed β we implement the protocol directly from the spec.
Understanding the Message Framing
ACP uses JSON-RPC 2.0 over stdio with newline delimiters. Each message is a single JSON object followed by \n.
Incoming (from editor/client):
Outgoing (from agent/us):
Responses (reply to a request, must include the same id):
Notifications (no id field, no response expected):
Implementation
Step 1: Define the JSON-RPC Message Types
Step 2: Define ACP-Specific Types
Step 3: The Agent State
Step 4: The Main Dispatch Loop
This is the core of the ACP agent: read a line from stdin, parse it as JSON-RPC, dispatch to the right handler, write the response(s) to stdout.
Building
Testing the Protocol Manually
Since ACP is newline-delimited JSON-RPC over stdio, you can test the protocol sequence directly with a shell pipeline. The messages must be sent sequentially as ACP is stateful.
Write a test script:
The output will be (simplified):
Registering with Zed
To use your agent in Zed, add it to Zed's agent configuration (check Zed's ACP documentation for the exact format, as it evolves with new releases):
What to Add Next
This is a skeleton. A production ACP agent built on this foundation would add:
Actual LLM Integration
Replace the placeholder response in handle_session_prompt with a call to an LLM API (e.g., OpenAI, Anthropic, a local Ollama instance). Stream tokens as they arrive using session/update notifications.
MCP Client Integration
The session/new params include a list of MCP servers for the agent to connect to. Use rmcp (the Rust MCP SDK) to connect to those servers and make tools available to your LLM:
File System Access (Client Methods)
If the client declares fs.readTextFile: true during initialization, you can call back to the client to read files:
Permission Gating
Before executing destructive tool calls, use session/request_permission:
Key Rules to Remember
All logging goes to stderr. stdout is the protocol wire. println! is off-limits. Use tracing with .with_writer(std::io::stderr).
Notifications have no id field. If you accidentally include one, the client may treat it as a response to a request.
session/prompt must not return until all session/update notifications are sent. The client considers the turn complete when it receives the session/prompt response.
Protocol version is an integer (1), not a date string. ACP uses "protocolVersion": 1, unlike MCP which uses "protocolVersion": "2025-06-18".
File paths must be absolute. The spec requires all path arguments to be absolute paths.
// Pseudocode for streaming LLM responses
while let Some(chunk) = llm_stream.next().await {
send_update(stdout, &session_id, SessionUpdate::AgentMessageChunk {
content: TextContent { content_type: "text", text: chunk },
}).await?;
}
// Connect to MCP servers listed in session/new
use rmcp::{ServiceExt, transport::TokioChildProcess};
for server_config in &session_params.mcp_servers {
if let Some(command) = &server_config.command {
let client = ().serve(TokioChildProcess::new(
tokio::process::Command::new(command)
)?).await?;
// Register client's tools with your LLM
}
}
// Request a file read from the client
let request = Request {
jsonrpc: "2.0".to_string(),
id: json!(next_id()),
method: "fs/read_text_file".to_string(),
params: Some(json!({ "path": "/absolute/path/to/file.rs" })),
};
write_message(stdout, &request).await?;
// Then read the response from stdin
let perm_request = Request {
jsonrpc: "2.0".to_string(),
id: json!(next_id()),
method: "session/request_permission".to_string(),
params: Some(json!({
"sessionId": session_id,
"permission": {
"type": "tool_call",
"toolName": "write_file",
"description": "Write to /src/main.rs"
}
})),
};
write_message(stdout, &perm_request).await?;
// Wait for approval before proceeding