# Building an ACP Agent from Scratch Using Rust

## The Situation with ACP and Rust

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](https://agentclientprotocol.com/get-started/agents) — 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:

1. Handles `initialize` — capability negotiation
2. Handles `session/new` — creates a session with a unique ID
3. Handles `session/prompt` — receives a user message, sends back streaming `session/update` notifications, and returns a final response
4. Runs over stdio

The actual AI logic is a placeholder — the focus here is the correct ACP framing and protocol flow.

***

## Project Setup

```bash
cargo new acp-agent --bin
cd acp-agent
```

```toml
# Cargo.toml
[package]
name = "acp-agent"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
```

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):**

```
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{...}}\n
{"jsonrpc":"2.0","id":1,"method":"session/new","params":{...}}\n
{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{...}}\n
```

**Outgoing (from agent/us):**

* **Responses** (reply to a request, must include the same `id`):

```
{"jsonrpc":"2.0","id":0,"result":{...}}\n
```

* **Notifications** (no `id` field, no response expected):

```
{"jsonrpc":"2.0","method":"session/update","params":{...}}\n
```

***

## Implementation

### Step 1: Define the JSON-RPC Message Types

```rust
// src/jsonrpc.rs
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Incoming JSON-RPC message from the client.
/// We use an untagged enum to handle requests (have id) vs notifications (no id).
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum IncomingMessage {
    Request(Request),
    Notification(Notification),
}

#[derive(Debug, Deserialize)]
pub struct Request {
    pub jsonrpc: String,
    pub id: Value,   // Can be number or string per JSON-RPC spec
    pub method: String,
    pub params: Option<Value>,
}

#[derive(Debug, Deserialize)]
pub struct Notification {
    pub jsonrpc: String,
    pub method: String,
    pub params: Option<Value>,
}

/// Outgoing JSON-RPC response.
#[derive(Debug, Serialize)]
pub struct Response {
    pub jsonrpc: &'static str,
    pub id: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<RpcError>,
}

impl Response {
    pub fn success(id: Value, result: Value) -> Self {
        Self {
            jsonrpc: "2.0",
            id,
            result: Some(result),
            error: None,
        }
    }

    pub fn error(id: Value, code: i32, message: impl Into<String>) -> Self {
        Self {
            jsonrpc: "2.0",
            id,
            result: None,
            error: Some(RpcError {
                code,
                message: message.into(),
            }),
        }
    }
}

/// Outgoing JSON-RPC notification (no id, no response expected).
#[derive(Debug, Serialize)]
pub struct OutgoingNotification {
    pub jsonrpc: &'static str,
    pub method: &'static str,
    pub params: Value,
}

impl OutgoingNotification {
    pub fn new(method: &'static str, params: Value) -> Self {
        Self {
            jsonrpc: "2.0",
            method,
            params,
        }
    }
}

#[derive(Debug, Serialize)]
pub struct RpcError {
    pub code: i32,
    pub message: String,
}

// Standard JSON-RPC error codes
pub const PARSE_ERROR: i32 = -32700;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
```

***

### Step 2: Define ACP-Specific Types

```rust
// src/acp_types.rs
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// The protocolVersion for ACP is an integer (unlike MCP which uses date strings).
/// Current version is 1.
pub const PROTOCOL_VERSION: u32 = 1;

// ─── initialize ──────────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
    pub protocol_version: u32,
    pub client_capabilities: Option<ClientCapabilities>,
    pub client_info: Option<PeerInfo>,
}

#[derive(Debug, Deserialize, Default)]
pub struct ClientCapabilities {
    pub fs: Option<FsCapabilities>,
    pub terminal: Option<bool>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsCapabilities {
    pub read_text_file: Option<bool>,
    pub write_text_file: Option<bool>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct PeerInfo {
    pub name: String,
    pub title: Option<String>,
    pub version: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
    pub protocol_version: u32,
    pub agent_capabilities: AgentCapabilities,
    pub agent_info: PeerInfo,
    pub auth_methods: Vec<Value>,
}

#[derive(Debug, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
    pub load_session: bool,
    pub prompt_capabilities: PromptCapabilities,
}

#[derive(Debug, Serialize, Default)]
pub struct PromptCapabilities {
    pub image: bool,
    pub audio: bool,
    #[serde(rename = "embeddedContext")]
    pub embedded_context: bool,
}

// ─── session/new ─────────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
    pub cwd: String,
    pub mcp_servers: Option<Vec<McpServerConfig>>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct McpServerConfig {
    pub name: String,
    pub command: Option<String>,
    pub args: Option<Vec<String>>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewResult {
    pub session_id: String,
}

// ─── session/prompt ──────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
    pub session_id: String,
    pub turn: PromptTurn,
}

#[derive(Debug, Deserialize)]
pub struct PromptTurn {
    pub content: Vec<ContentBlock>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
    Text { text: String },
    // Other types (image, audio, resource) omitted for this minimal implementation
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptResult {
    pub stop_reason: StopReason,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
    EndTurn,
    MaxTokens,
    StopSequence,
    Error,
}

// ─── session/update notification ─────────────────────────────────────────────

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdateParams {
    pub session_id: String,
    pub update: SessionUpdate,
}

#[derive(Debug, Serialize)]
#[serde(tag = "sessionUpdate", rename_all = "snake_case")]
pub enum SessionUpdate {
    AgentMessageChunk {
        content: TextContent,
    },
    // Other update types: thought_chunk, tool_call, diff, etc.
}

#[derive(Debug, Serialize)]
pub struct TextContent {
    #[serde(rename = "type")]
    pub content_type: &'static str,
    pub text: String,
}
```

***

### Step 3: The Agent State

```rust
// src/agent.rs
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

/// Represents an active conversation session.
pub struct Session {
    pub id: String,
    pub cwd: String,
}

/// Shared state for the agent.
#[derive(Clone)]
pub struct AgentState {
    sessions: Arc<Mutex<HashMap<String, Session>>>,
}

impl AgentState {
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(Mutex::new(HashMap::new())),
        }
    }

    /// Creates a new session and returns its ID.
    pub fn create_session(&self, cwd: String) -> String {
        let id = format!("sess_{}", Uuid::new_v4().simple());
        let session = Session {
            id: id.clone(),
            cwd,
        };
        self.sessions.lock().unwrap().insert(id.clone(), session);
        id
    }

    /// Checks if a session exists.
    pub fn session_exists(&self, id: &str) -> bool {
        self.sessions.lock().unwrap().contains_key(id)
    }
}
```

***

### 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.

```rust
// src/main.rs
mod jsonrpc;
mod acp_types;
mod agent;

use anyhow::Result;
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

use acp_types::*;
use agent::AgentState;
use jsonrpc::*;

/// Write a single JSON message to stdout followed by newline.
/// This is the only function that writes to stdout.
async fn write_message<T: serde::Serialize>(
    stdout: &mut tokio::io::Stdout,
    msg: &T,
) -> Result<()> {
    let mut line = serde_json::to_string(msg)?;
    line.push('\n');
    stdout.write_all(line.as_bytes()).await?;
    stdout.flush().await?;
    Ok(())
}

/// Send a session/update notification — used for streaming output.
async fn send_update(
    stdout: &mut tokio::io::Stdout,
    session_id: &str,
    update: SessionUpdate,
) -> Result<()> {
    let notification = OutgoingNotification::new(
        "session/update",
        serde_json::to_value(SessionUpdateParams {
            session_id: session_id.to_string(),
            update,
        })?,
    );
    write_message(stdout, &notification).await
}

/// Handle the `initialize` request.
async fn handle_initialize(
    params: Option<Value>,
) -> Result<Value> {
    // Parse params (tolerant of missing fields)
    let _params: Option<InitializeParams> = params
        .as_ref()
        .and_then(|p| serde_json::from_value(p.clone()).ok());

    let result = InitializeResult {
        protocol_version: PROTOCOL_VERSION,
        agent_capabilities: AgentCapabilities {
            load_session: false,
            prompt_capabilities: PromptCapabilities {
                image: false,
                audio: false,
                embedded_context: false,
            },
        },
        agent_info: PeerInfo {
            name: "acp-agent-rs".to_string(),
            title: Some("ACP Agent (Rust)".to_string()),
            version: Some(env!("CARGO_PKG_VERSION").to_string()),
        },
        auth_methods: vec![],
    };

    Ok(serde_json::to_value(result)?)
}

/// Handle the `session/new` request.
async fn handle_session_new(
    state: &AgentState,
    params: Option<Value>,
) -> Result<Value> {
    let params: SessionNewParams = params
        .ok_or_else(|| anyhow::anyhow!("Missing params"))
        .and_then(|p| serde_json::from_value(p).map_err(Into::into))?;

    let session_id = state.create_session(params.cwd);

    Ok(serde_json::to_value(SessionNewResult { session_id })?)
}

/// Handle the `session/prompt` request.
///
/// This is the main turn handler. We:
/// 1. Parse the incoming prompt
/// 2. Send streaming session/update notifications as we "process"
/// 3. Return the final session/prompt response with stop_reason
async fn handle_session_prompt(
    state: &AgentState,
    stdout: &mut tokio::io::Stdout,
    params: Option<Value>,
) -> Result<Value> {
    let params: SessionPromptParams = params
        .ok_or_else(|| anyhow::anyhow!("Missing params"))
        .and_then(|p| serde_json::from_value(p).map_err(Into::into))?;

    if !state.session_exists(&params.session_id) {
        anyhow::bail!("Unknown session: {}", params.session_id);
    }

    // Extract text from the user's content blocks
    let user_text: String = params
        .turn
        .content
        .iter()
        .filter_map(|block| match block {
            ContentBlock::Text { text } => Some(text.as_str()),
        })
        .collect::<Vec<_>>()
        .join(" ");

    tracing::info!("User prompt in session {}: {}", params.session_id, user_text);

    // Stream the response back using session/update notifications.
    // In a real agent this is where you'd call an LLM and stream tokens.
    let response_text = format!(
        "I received your message: \"{}\". \
        (This is a placeholder response from the Rust ACP agent. \
        Wire up an LLM here.)",
        user_text
    );

    // Send the response in chunks to demonstrate streaming
    for chunk in response_text.split_whitespace().collect::<Vec<_>>().chunks(5) {
        let text = chunk.join(" ") + " ";
        send_update(
            stdout,
            &params.session_id,
            SessionUpdate::AgentMessageChunk {
                content: TextContent {
                    content_type: "text",
                    text,
                },
            },
        )
        .await?;
    }

    // Return the final response with stop reason
    let result = SessionPromptResult {
        stop_reason: StopReason::EndTurn,
    };

    Ok(serde_json::to_value(result)?)
}

#[tokio::main]
async fn main() -> Result<()> {
    // IMPORTANT: Log to stderr only.
    // stdout is the ACP protocol wire — any stray bytes will corrupt the framing.
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(tracing::Level::INFO.into()),
        )
        .with_writer(std::io::stderr)
        .with_ansi(false)
        .init();

    tracing::info!("ACP agent starting");

    let state = AgentState::new();
    let stdin = tokio::io::stdin();
    let mut stdout = tokio::io::stdout();
    let mut reader = BufReader::new(stdin);
    let mut line = String::new();

    loop {
        line.clear();
        let bytes_read = reader.read_line(&mut line).await?;

        // EOF — the client closed its end of the connection
        if bytes_read == 0 {
            tracing::info!("EOF on stdin, shutting down");
            break;
        }

        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        tracing::debug!("Received: {}", trimmed);

        // Parse as JSON-RPC
        let message: IncomingMessage = match serde_json::from_str(trimmed) {
            Ok(m) => m,
            Err(e) => {
                tracing::warn!("Failed to parse message: {}", e);
                // Send parse error back (use null id per JSON-RPC spec for unparseable messages)
                let err_response =
                    Response::error(Value::Null, PARSE_ERROR, format!("Parse error: {}", e));
                write_message(&mut stdout, &err_response).await?;
                continue;
            }
        };

        match message {
            IncomingMessage::Request(req) => {
                tracing::debug!("Handling request: {} (id: {})", req.method, req.id);

                let result = match req.method.as_str() {
                    "initialize" => {
                        handle_initialize(req.params).await
                    }
                    "session/new" => {
                        handle_session_new(&state, req.params).await
                    }
                    "session/prompt" => {
                        // Note: handle_session_prompt writes notifications to stdout
                        // before returning the final result.
                        handle_session_prompt(&state, &mut stdout, req.params).await
                    }
                    unknown => {
                        Err(anyhow::anyhow!("Unknown method: {}", unknown))
                    }
                };

                let response = match result {
                    Ok(value) => Response::success(req.id, value),
                    Err(e) => {
                        tracing::error!("Error handling {}: {}", req.method, e);
                        if req.method == "initialize" || req.method == "session/new" {
                            Response::error(req.id, INVALID_PARAMS, e.to_string())
                        } else {
                            Response::error(req.id, METHOD_NOT_FOUND, e.to_string())
                        }
                    }
                };

                write_message(&mut stdout, &response).await?;
            }

            IncomingMessage::Notification(notif) => {
                // Clients can send session/cancel as a notification
                tracing::debug!("Received notification: {}", notif.method);
                match notif.method.as_str() {
                    "session/cancel" => {
                        tracing::info!("Session cancel received");
                        // In a real implementation, cancel ongoing operations
                    }
                    _ => {
                        tracing::debug!("Unhandled notification: {}", notif.method);
                    }
                }
            }
        }
    }

    Ok(())
}
```

***

## Building

```bash
cargo build --release
```

***

## 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:

```bash
#!/bin/bash
# test_acp.sh

AGENT=./target/release/acp-agent

(
  # 1. Initialize
  echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true}},"clientInfo":{"name":"test-client","version":"1.0.0"}}}'
  sleep 0.1

  # 2. Create a session
  echo '{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp/project","mcpServers":[]}}'
  sleep 0.1

  # 3. Send a prompt (use the sessionId from the session/new response)
  echo '{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"REPLACE_WITH_SESSION_ID","turn":{"content":[{"type":"text","text":"Hello, what can you do?"}]}}}'
  sleep 0.5
) | $AGENT 2>/dev/null
```

The output will be (simplified):

```jsonl
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{...},"agentInfo":{...},"authMethods":[]}}
{"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess_a1b2c3d4..."}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_a1b2c3d4...","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I received ..."}}}}
{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}
```

***

## Registering with Zed

To use your agent in Zed, add it to Zed's agent configuration (check [Zed's ACP documentation](https://zed.dev) for the exact format, as it evolves with new releases):

```json
{
  "agent": {
    "provider": {
      "type": "acp",
      "command": "/path/to/acp-agent/target/release/acp-agent",
      "args": []
    }
  }
}
```

***

## 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.

```rust
// 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?;
}
```

### 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:

```rust
// 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
    }
}
```

### File System Access (Client Methods)

If the client declares `fs.readTextFile: true` during initialization, you can call back to the client to read files:

```rust
// 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
```

### Permission Gating

Before executing destructive tool calls, use `session/request_permission`:

```rust
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
```

***

## Key Rules to Remember

1. **All logging goes to stderr.** stdout is the protocol wire. `println!` is off-limits. Use `tracing` with `.with_writer(std::io::stderr)`.
2. **Notifications have no `id` field.** If you accidentally include one, the client may treat it as a response to a request.
3. **`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.
4. **Protocol version is an integer (1), not a date string.** ACP uses `"protocolVersion": 1`, unlike MCP which uses `"protocolVersion": "2025-06-18"`.
5. **File paths must be absolute.** The spec requires all path arguments to be absolute paths.

***

## Protocol Spec Reference

* [ACP Protocol Overview](https://agentclientprotocol.com/protocol/overview)
* [ACP Initialization](https://agentclientprotocol.com/protocol/initialization)
* [ACP Session Setup](https://agentclientprotocol.com/protocol/session-setup)
* [ACP Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn)
* [ACP GitHub Repository](https://github.com/agentclientprotocol/agent-client-protocol)
