ACP Guide

View Source

The Agent Client Protocol (ACP) is a standardized protocol for controlling coding agents programmatically. ExMCP includes a full ACP client implementation, letting you start agent sessions, send prompts, receive streaming updates, and handle permission requests — all from Elixir. It also includes ExMCP.ACP.Agent for building native Elixir ACP agents.

Overview

ACP uses JSON-RPC 2.0 over stdio (the same wire format as MCP) with methods for session management and bidirectional communication. Most coding agents speak ACP natively. For agents with their own protocols (Claude Code, Codex, Pi), ExMCP provides an adapter system that translates between ACP and the agent's native protocol.

Architecture

Your Elixir App
    
    
ExMCP.ACP.Client (GenServer)
    
     Native ACP agents (Gemini CLI, Hermes, OpenCode, Qwen Code, ...)
            stdio JSON-RPC directly
    
     Adapted agents (Claude Code, Codex, Pi)
             AdapterBridge  Adapter  agent-native protocol

ACP Client
    
    
ExMCP.ACP.Agent (GenServer)
    
     Your Elixir handler

Quick Start

Native ACP Agent

# Start a client connected to a native ACP agent
{:ok, client} = ExMCP.ACP.start_client(command: ["gemini", "--acp"])

# Create a session rooted at a project directory
{:ok, %{"sessionId" => session_id}} =
  ExMCP.ACP.Client.new_session(client, "/path/to/project")

# Send a prompt and wait for the result
{:ok, %{"stopReason" => reason}} =
  ExMCP.ACP.Client.prompt(client, session_id, "Fix the failing tests")

# Cancel a running prompt
ExMCP.ACP.Client.cancel(client, session_id)

# Clean up
ExMCP.ACP.Client.disconnect(client)

Native Elixir ACP Agent

Use ExMCP.ACP.Agent when your Elixir application is the agent being controlled by an ACP client:

defmodule MyApp.EchoAgent do
  @behaviour ExMCP.ACP.Agent.Handler

  @impl true
  def init(_opts), do: {:ok, %{}}

  @impl true
  def handle_new_session(_params, _ctx, state) do
    {:reply, %{"sessionId" => "sess_" <> Base.encode16(:crypto.strong_rand_bytes(8))}, state}
  end

  @impl true
  def handle_prompt(session_id, prompt, ctx, state) do
    text = prompt |> List.first() |> Map.get("text", "")

    ExMCP.ACP.Agent.agent_message(ctx.agent, session_id, "Echo: " <> text)
    {:reply, %{"stopReason" => "end_turn"}, state}
  end
end

ExMCP.ACP.run_agent(
  handler: MyApp.EchoAgent,
  agent_info: %{"name" => "echo-agent", "version" => "1.0.0"}
)

Prompt handlers can also stream updates and finish asynchronously:

def handle_prompt(session_id, _prompt, ctx, state) do
  Task.start(fn ->
    ExMCP.ACP.Agent.agent_message(ctx.agent, session_id, "Working...")
    ExMCP.ACP.Agent.finish_prompt(ctx.agent, ctx.prompt_id, "end_turn")
  end)

  {:noreply, state}
end

Adapted Agent (Claude Code)

{:ok, client} = ExMCP.ACP.start_client(
  command: ["claude"],
  adapter: ExMCP.ACP.Adapters.ClaudeSDK,
  adapter_opts: [model: "sonnet", cwd: "/my/project"]
)

{:ok, %{"sessionId" => sid}} = ExMCP.ACP.Client.new_session(client, "/my/project")
{:ok, result} = ExMCP.ACP.Client.prompt(client, sid, "Refactor the auth module")

Use ExMCP.ACP.Adapters.ClaudeSDK for new Claude Code integrations. It speaks the same SDK-style control protocol used by the official Claude Agent SDK, so it can bridge permission prompts, partial tool lifecycle events, cancellation, session setup, model/mode config, and richer status updates.

Adapted Agent (Codex)

{:ok, client} = ExMCP.ACP.start_client(
  command: ["codex"],
  adapter: ExMCP.ACP.Adapters.Codex,
  adapter_opts: [model: "gpt-4o"]
)

Adapted Agent (Pi)

{:ok, client} = ExMCP.ACP.start_client(
  command: ["pi"],
  adapter: ExMCP.ACP.Adapters.Pi,
  adapter_opts: [
    model: "anthropic/claude-sonnet-4",
    thinking_level: "medium",
    session_path: "/path/to/session.jsonl"  # optional: resume session
  ]
)

Client Options

OptionDefaultDescription
:command(required)Command list for the agent subprocess
:adapternilAdapter module for non-native agents
:adapter_opts[]Options passed to adapter's init/1
:handlerDefaultHandlerModule implementing ExMCP.ACP.Client.Handler
:handler_opts[]Options passed to handler.init/1
:event_listenernilPID to receive {:acp_session_update, sid, update} messages
:client_info%{"name" => "ex_mcp", ...}Client identification
:capabilities%{}Client capabilities map
:protocol_version1ACP protocol version (integer)
:namenilGenServer name registration

Session Lifecycle

ACP sessions represent ongoing conversations with an agent.

# Create a new session
{:ok, %{"sessionId" => sid}} = ExMCP.ACP.Client.new_session(client, "/project",
  additional_directories: ["/shared/docs"],
  mcp_servers: [
    ExMCP.ACP.Types.http_mcp_server("my-server", "http://localhost:3000/mcp")
  ]
)

# Load an existing session and replay conversation history
{:ok, _} = ExMCP.ACP.Client.load_session(client, sid, "/project")

# Resume an existing session without replaying history (if supported)
{:ok, _} = ExMCP.ACP.Client.resume_session(client, sid, "/project")

# List available sessions with optional filters (if supported)
{:ok, %{"sessions" => sessions}} =
  ExMCP.ACP.Client.list_sessions(client,
    cwd: "/project",
    additional_directories: ["/shared/docs"]
  )

# Delete a session from session history (if supported)
{:ok, %{}} = ExMCP.ACP.Client.delete_session(client, sid)

# Send prompts (blocks until agent responds)
{:ok, result} = ExMCP.ACP.Client.prompt(client, sid, "Add error handling")

# Cancel a running prompt
ExMCP.ACP.Client.cancel(client, sid)

# Configure the agent at runtime
ExMCP.ACP.Client.set_mode(client, sid, "high")
ExMCP.ACP.Client.set_model(client, sid, "anthropic/claude-sonnet-4")
ExMCP.ACP.Client.set_config_option(client, sid, "auto_retry", false)

# Close a session and free agent-side resources (if supported)
ExMCP.ACP.Client.close_session(client, sid)

# Authenticate or logout (if agent requires/supports it)
ExMCP.ACP.Client.authenticate(client, "api-key")
ExMCP.ACP.Client.logout(client)

Handling Session Events

Implement the ExMCP.ACP.Client.Handler behaviour to react to streaming updates and agent requests:

defmodule MyApp.ACPHandler do
  @behaviour ExMCP.ACP.Client.Handler

  @impl true
  def init(_opts), do: {:ok, %{}}

  @impl true
  def handle_session_update(session_id, update, state) do
    case update["sessionUpdate"] do
      "agent_message_chunk" ->
        IO.write(update["content"]["text"])

      "tool_call_update" ->
        status = update["status"]  # "pending", "in_progress", "completed", or "failed"
        IO.puts("[#{status}] #{update["title"]}")

        # Rich metadata available for ClaudeSDK/Codex/Pi adapters:
        # update["kind"]      — "read", "edit", "execute", "search", "think"
        # update["locations"] — [%{"path" => "/src/app.ex", "line" => 10}]
        # update["content"]   — [%{"type" => "diff", "oldText" => ..., "newText" => ...}]

      "plan" ->
        for entry <- update["entries"] do
          IO.puts("  [#{entry["status"]}] #{entry["content"]}")
        end

      "agent_thought_chunk" ->
        IO.write(update["content"]["text"])

      "usage" ->
        IO.puts("Tokens: #{update["inputTokens"]} in / #{update["outputTokens"]} out")

      _ ->
        :ok
    end

    {:ok, state}
  end

  @impl true
  def handle_permission_request(_session_id, tool_call, options, state) do
    reject_option = Enum.find(options, &(&1["kind"] == "reject_once")) || List.first(options)

    # Return the direct outcome; ExMCP wraps it as result.outcome on the wire.
    case reject_option do
      nil -> {:ok, %{"outcome" => "cancelled"}, state}
      option -> {:ok, %{"outcome" => "selected", "optionId" => option["optionId"]}, state}
    end
  end

  # Optional: handle file read requests from the agent
  def handle_file_read(_session_id, path, _opts, state) do
    case File.read(path) do
      {:ok, content} -> {:ok, content, state}
      {:error, reason} -> {:error, to_string(reason), state}
    end
  end

  # Optional: handle terminal requests from the agent
  def handle_terminal_request(method, params, _id, state) do
    # Handle terminal/create, terminal/output, terminal/kill, etc.
    {:error, "Terminal operations not implemented", state}
  end
end

Event Listener

For simple use cases, receive session updates as process messages instead of implementing a full handler:

{:ok, client} = ExMCP.ACP.start_client(
  command: ["gemini", "--acp"],
  event_listener: self()
)

# In your receive loop or GenServer
receive do
  {:acp_session_update, session_id, %{"sessionUpdate" => type} = update} ->
    IO.puts("#{type}: #{inspect(update)}")
end

Session Update Types

The ACP spec defines these session update types (all supported by ExMCP):

TypeDescription
agent_message_chunkStreaming text/image content from the agent
user_message_chunkEcho of user input
agent_thought_chunkStreaming thought content from the agent
tool_callNew tool call started
tool_call_updateTool call lifecycle (pending → in_progress → completed/failed)
planMulti-step execution plan with entry status
available_commands_updateSlash commands the agent supports
config_option_updateRuntime config change notification
current_mode_updateOperational mode change
session_info_updateSession metadata such as title and updatedAt
usage_updateContext window usage and optional cost information

Adapter-specific status, error, and extension bridge details are attached under _meta.ex_mcp on spec-defined update types, usually session_info_update.

Writing Custom Adapters

To support an agent that doesn't speak ACP natively, implement the ExMCP.ACP.Adapter behaviour:

defmodule MyApp.CustomAgentAdapter do
  @behaviour ExMCP.ACP.Adapter

  @impl true
  def init(opts), do: {:ok, %{model: Keyword.get(opts, :model, "default")}}

  @impl true
  def command(_opts), do: {"my-agent", ["--json-mode"]}

  @impl true
  def capabilities, do: %{}

  # Optional: declare supported modes
  @impl true
  def modes do
    [%{"id" => "fast", "name" => "Fast Mode"}, %{"id" => "quality", "name" => "Quality Mode"}]
  end

  # Optional: declare config options
  @impl true
  def config_options do
    [
      %{
        "id" => "model",
        "name" => "Model",
        "category" => "model",
        "type" => "select",
        "currentValue" => "fast",
        "options" => [
          %{"value" => "fast", "name" => "Fast"},
          %{"value" => "quality", "name" => "Quality"}
        ]
      }
    ]
  end

  # Optional: list available sessions
  @impl true
  def list_sessions(params, state) do
    sessions = [
      %{
        "sessionId" => "sess-1",
        "cwd" => params["cwd"] || state.cwd,
        "title" => "My Session"
      }
    ]

    {:ok, sessions, state}
  end

  @impl true
  def translate_outbound(%{"method" => "session/prompt", "params" => params}, state) do
    text = hd(params["prompt"])["text"]
    {:ok, [Jason.encode!(%{"action" => "ask", "text" => text}), "\n"], state}
  end

  def translate_outbound(_msg, state), do: {:ok, :skip, state}

  @impl true
  def translate_inbound(line, state) do
    case Jason.decode(line) do
      {:ok, %{"type" => "stream", "delta" => delta}} ->
        notification = %{
          "jsonrpc" => "2.0",
          "method" => "session/update",
          "params" => %{
            "sessionId" => "default",
            "update" => %{
              "sessionUpdate" => "agent_message_chunk",
              "content" => %{"type" => "text", "text" => delta}
            }
          }
        }
        {:messages, [notification], state}

      _ ->
        {:skip, state}
    end
  end
end

Adapter Callbacks

CallbackRequiredDescription
init/1YesInitialize adapter state
command/1YesReturn {executable, args}, :one_shot, or :adapter_managed
translate_outbound/2YesConvert ACP message to native format
translate_inbound/2YesConvert native output to ACP messages
post_connect/1NoSend initial data after port opens
handle_adapter_message/2NoHandle Port/process messages for adapter-managed subprocesses
shutdown/1NoClean up adapter-managed resources when the bridge closes
env/1NoReturn child-process environment variables
capabilities/0NoReturn static agent capabilities
modes/0NoReturn supported operational modes
config_options/0NoReturn supported config options
auth_methods/1NoReturn initialize authMethods for adapter options
list_sessions/2NoReturn a sessions list or full ACP session/list result for decoded params
fork_session/2NoFork an existing session for decoded session/fork params

Built-in Adapters

Claude Code SDK (ExMCP.ACP.Adapters.ClaudeSDK)

Translates between ACP and Claude Code's SDK-compatible stream-json control protocol. This is the recommended Claude adapter for new code.

Features:

  • SDK entrypoint launch environment and --permission-prompt-tool stdio
  • Partial message and pending tool-call lifecycle mapping
  • session/cancel via SDK interrupt
  • ACP permission requests bridged from Claude SDK can_use_tool
  • Runtime model, permission mode, and effort config controls
  • Live session setup/load/resume/fork/close ACP surface
  • Disk-backed session/list, session/delete, and session/fork for Claude Code's SDK store
  • Full session/load replay from persisted Claude JSONL transcripts
  • FIFO prompt queueing with queued prompt cancellation responses
  • Plan updates from TodoWrite and task progress events
  • Rich tool metadata, terminal/raw output metadata, and improved stop reasons
  • Official ACP mcpCapabilities plus ExMCP _meta support for BEAM-local MCP transport

session/list, session/load, session/fork, and session/delete read and mutate Claude Code's local CLAUDE_CONFIG_DIR/projects JSONL store directly in Elixir, using the same project-key derivation, UUID validation, sidechain filtering, and title sanitization rules as the official Claude Agent SDK. session/load replays persisted transcript entries as ACP session/update notifications before the load response; session/resume keeps the lighter no-replay behavior.

The adapter advertises official ACP MCP support through mcpCapabilities (acp, http, and sse). ExMCP's BEAM-local MCP transport is intentionally advertised only as _meta.ex_mcp.mcpCapabilities.beam, so other ACP libraries can ignore it while ExMCP peers can negotiate and validate BEAM-local descriptors.

Startup options: model, permission_mode, max_thinking_tokens, effort, additional_directories, mcp_servers, session_id, resume, resume_session_at, allowed_tools, disallowed_tools, tools, strict_mcp_config, include_partial_messages, and cli_path.

Codex (ExMCP.ACP.Adapters.Codex)

Translates between ACP and Codex's app-server JSON-RPC protocol.

Features:

  • Initialize handshake with post_connect/1
  • Model catalog loading from Codex model/list, ACP session/set_model, and per-session models state
  • Tool call lifecycle: creation, completion, output, patch events, and current camelCase app-server item variants
  • Command execution streaming (started/outputDelta/completed)
  • Web search, MCP tool, dynamic tool, file change, image generation, plan, status, goal, and compaction events
  • Session list/load/resume/close through Codex app-server thread APIs
  • Load-history replay from returned Codex turns when available, including tool history
  • Image content, resource links, and embedded text resources in prompts
  • Codex slash commands in prompts: /compact, /init, /review, /review-branch, /review-commit, and /logout
  • ACP HTTP and stdio MCP server descriptors forwarded into Codex session config
  • Codex auth methods for ChatGPT login and explicit CODEX_API_KEY/OPENAI_API_KEY adapter env
  • Approval and MCP elicitation requests bridged through ACP session/request_permission

Modes: read-only, auto, full-access. Legacy suggest, auto-edit, and full-auto aliases are no longer accepted. Config options: mode, model, and reasoning_effort are returned with Codex session responses and updated with thread/settings/update.

Unsupported Codex app-server requests: Dynamic tool calls, request-user-input prompts, ChatGPT token refresh, and attestation generation are rejected explicitly because ACP does not provide compatible structured responses for those app-server request schemas.

Pi (ExMCP.ACP.Adapters.Pi)

Translates between ACP and Pi's RPC NDJSON protocol.

Features:

  • Adapter-managed Pi subprocesses for ACP session/new, session/load, and session/resume
  • ACP-native session/new, session/load, session/resume, session/list, session/close, session/delete, session/prompt, session/cancel, session/set_model, and session/set_mode
  • Terminal authentication method advertisement through authMethods
  • Pi session discovery from JSONL files plus a local ExMCP session map at ~/.ex_mcp/pi/session-map.json, with cursor pagination and last-cwd default filtering
  • Prompt queuing while another Pi turn is active
  • Global/project Pi settings merge for skill command filtering and quiet startup
  • Startup info for Pi version, context, prompts, skills, extensions, and captured CLI prelude; registry update notices are opt-in
  • Markdown slash commands loaded from ~/.pi/agent/prompts and <cwd>/.pi/prompts
  • Built-in slash commands: /compact, /autocompact, /export, /session, /name, /steering, /follow-up, and /changelog
  • Text/thinking streaming, tool-call streaming, tool execution lifecycle, compaction, retry, and extension UI metadata events
  • Enhanced tool result parsing with content blocks, structured edit diffs, stdout/stderr/exitCode formatting, and file locations
  • Image support with data-url prefix stripping
  • Resource links and embedded text resources folded into Pi prompt text; audio blocks are represented as unsupported markers

Modes: off, minimal, low, medium, high, xhigh map to Pi thinking levels through ACP session/set_mode.

Config options: auto_compaction, auto_retry, steering_mode, follow_up_mode. Model changes use ExMCP.ACP.Client.set_model/3 rather than a config option.

Startup options: cli_path/pi_command, session_dir, session_map_path, delete_session_files, and update_notice. session/delete removes ExMCP session-map state by default; backing Pi JSONL files are deleted only when delete_session_files: true is set and the file is under the configured Pi session directory. Registry update checks are disabled unless update_notice: true or PI_ACP_UPDATE_NOTICE=true is set.

Breaking change: Pi-specific _ex_mcp.pi/* and legacy pi/* extension methods are no longer implemented. Use the ACP session methods above or slash commands in prompts.

Content Block Types

ACP supports these content block types in prompts and responses:

alias ExMCP.ACP.Types

# Text
Types.text_block("Hello, world!")

# Images
Types.image_block("image/png", "base64data...")

# Audio
Types.audio_block("audio/wav", "base64data...")

# Resource links (references to external resources)
Types.resource_link_block("file:///src/app.ex", name: "app.ex")

# Embedded resources
Types.resource_block("file:///src/app.ex", text: "defmodule App do...")

# Plan entries
Types.plan_entry("Fix the auth bug", "high", "in_progress")

# Plan update notification (emits the stable "plan" update type)
Types.plan_update(session_id, [
  Types.plan_entry("Read the code", "high", "completed"),
  Types.plan_entry("Write the fix", "high", "in_progress"),
  Types.plan_entry("Run tests", "medium", "pending")
])

MCP Server Integration

ACP agents can use MCP servers as tool providers. Pass MCP server configurations when creating sessions:

{:ok, %{"sessionId" => sid}} = ExMCP.ACP.Client.new_session(client, "/project",
  additional_directories: ["/shared/docs"],
  mcp_servers: [
    ExMCP.ACP.Types.stdio_mcp_server("local-tools", "my_mcp_server", args: ["--stdio"]),
    ExMCP.ACP.Types.http_mcp_server("remote-tools", "http://localhost:4000/mcp")
  ]
)

ACP Registry

The public ACP Registry lists ACP-compatible agents and their distribution metadata:

{:ok, registry} = ExMCP.ACP.Registry.fetch()

agent = ExMCP.ACP.Registry.get_agent(registry, "codex-acp")
{:ok, command} = ExMCP.ACP.Registry.npx_command(agent)

{:ok, client} = ExMCP.ACP.start_client(command: command)

Use ExMCP.ACP.Registry.find_agents/2 to search the decoded registry by agent id, name, or description.

API Reference