Fragments of verbose memory

冗長な記憶の断片 - Web技術のメモをほぼ毎日更新

Feb 28, 2026 - 日記

ACP実装ガイド: エディタとコーディングエージェントの共通語

ACP実装ガイド cover image

Agent Client Protocol (ACP)は、コードエディタ/IDEとコーディングエージェントの通信を標準化するプロトコルです。最近はAIエージェントの選択肢が増えましたが、実際の連携は各エディタの個別実装に依存しがちです。本記事では、エディタ開発者がすぐに統合を始められる粒度で、仕様の要点と実装の入口をまとめます。仕様の出典は公式ドキュメントGitHubのリポジトリ です。

結論だけ言うと、ACPは「エディタとエージェントの間に共通語を作る」ための仕組みです。結果として、AILLM エージェントの乗り換えや、異なるエディタ間での互換性が取りやすくなります。

ACPが解決したい課題

ACPが問題視しているのは、エディタとエージェントの密結合です。具体的には次のような痛みがあります。

  • エディタが新しいエージェントを使うたびに個別実装が必要
  • エージェントが対応できるエディタが限定される
  • ユーザーがエージェントを選ぶと、使えるUIやエディタが縛られる

これは、Language Server Protocol (LSP)がエディタと解析ツールを分離した構図に近いです。ACPも同様に、エディタとエージェントを疎結合にするための共通プロトコルとして設計されています。

ACPの概要: エディタ実装の最短ルート

ACPは「ユーザーは主にエディタ上で作業し、必要に応じてエージェントを呼び出す」前提です。エディタ側の実装に必要な最低限を並べると次の通りです。

  1. エージェントをサブプロセスとして起動(stdio: 標準入出力)
  2. JSON-RPC 2.0(JSONで行うRPC仕様)のメッセージを改行区切りで送受信
  3. initializesession/newsession/prompt の順で会話を開始
  4. session/update 通知をストリームとして受け取ってUIに反映

ACPのメッセージは改行で区切られる1行JSONが基本です。stdoutにACP以外の文字列を出さないこと、stdinにもACP以外を書かないことが重要です(Transports )。

実装目線で重要な仕様を先にまとめます。

  • 通知とリクエスト(notification と method): JSON-RPCなので、idが無いmethodは通知(返答不要)、idがあるmethodはリクエスト(返答が必要)です(Protocol Overview )。
  • 双方向: クライアントがエージェントへリクエストを送るだけではなく、エージェントがクライアントへsession/request_permissionfs/read_text_file等のリクエストを飛ばします。エディタ側はサーバ実装だと思ってください。
  • stdoutはプロトコル専用: エージェントはstdoutにACPメッセージ以外を出してはいけません。ログはstderrに出す、が前提です(Transports)。
  • 改行の扱い: メッセージは\n区切りで、1メッセージの中に生の改行を入れません(JSON文字列の\nエスケープはOK)。JSONのpretty print(インデント付き)はやめた方が安全です。
  • 絶対パス: ACP内のファイルパスは絶対パスが前提です(Protocol OverviewのArgument Requirements)。
  • 行番号は1-based: fs/read_text_filelineなど、行番号は1始まりです(同じくOverview)。

実装チェックリスト(エディタ側)

ここだけ実装できれば、とりあえず「ACP対応エージェントと会話できる」状態になります。

  1. stdioでエージェントを起動して、stdin/stdoutで1行JSONを送受信
  2. JSON-RPCのディスパッチ(通知/リクエスト/レスポンス)
  3. initializesession/newsession/prompt
  4. session/updateをストリームとしてUIに反映(テキスト/ツール呼び出し/差分など)
  5. エージェントからのsession/request_permissionに応答(許可UIの実装)
  6. 必要ならfs/*terminal/*を実装してcapabilitiesに載せる

まず実装するメッセージフロー

Protocol Overviewの「Message Flow」が最短の道しるべです。最低限の流れは次の4ステップです。

  1. initialize: プロトコルバージョンとcapabilities(利用機能の宣言)を交渉
  2. session/new: セッションを作成してsessionIdを取得
  3. session/prompt: ユーザーの入力を送信
  4. session/update: エージェントからの進捗/結果を受信

initializeでは、クライアント側が「ファイル読み書きやターミナル操作を提供できるか」をcapabilitiesで宣言します(Initialization )。ここで宣言した機能だけが、エージェント側から要求されます。

エージェントとクライアントのやり取りは、ざっくりこういうシーケンスです。

sequenceDiagram
  participant Client as Editor/Client
  participant Agent as Agent

  Client->>Agent: initialize
  Agent-->>Client: initialize result (version/capabilities)
  Client->>Agent: session/new
  Agent-->>Client: session/new result (sessionId)

  Client->>Agent: session/prompt (user message)
  Agent-->>Client: session/update (plan/message chunks/tool calls...)
  Agent->>Client: session/request_permission (optional)
  Client-->>Agent: permission result
  Agent-->>Client: session/update (tool_call_update)
  Agent-->>Client: session/prompt result (stopReason)

initialize(バージョン/機能の交渉)

initializeは最初の握手です。クライアントがサポートするプロトコルバージョンと、提供できる機能(capabilities)を送ります。

例(クライアント→エージェント):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": 1,
    "clientCapabilities": {
      "fs": {
        "readTextFile": true,
        "writeTextFile": true
      },
      "terminal": true
    },
    "clientInfo": {
      "name": "my-client",
      "title": "My Client",
      "version": "1.0.0"
    }
  }
}

ここでcapabilitiesを盛りすぎると、エージェントが当然のようにfs/read_text_fileを呼びます。 最初はterminal: falsefsもfalseにして、会話だけ成立させるのが安全です。

session/new(会話のコンテキスト作成)

次にsession/newでセッションを作ります(Session Setup )。返ってくるsessionIdを以降のやり取りで使います。

1
2
3
4
5
6
7
8
9
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "session/new",
  "params": {
    "cwd": "/home/user/project",
    "mcpServers": []
  }
}

mcpServersは拡張サーバ設定で、使わないなら空配列のままでOKです。

cwdは絶対パスです。ここを間違えると「意図しない場所のファイルをいじる」事故が起きます。

session/prompt と session/update(ストリーミング表示)

プロンプトターン(Prompt Turn)はsession/promptから始まり、session/update通知が大量に流れて、最後にsession/promptレスポンスが返って終わります(Prompt Turn )。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "session/prompt",
  "params": {
    "sessionId": "sess_abc123def456",
    "prompt": [
      {
        "type": "text",
        "text": "READMEを要約して"
      }
    ]
  }
}

ストリーミング中に受け取るsession/updateの例:

  • plan: タスク計画(UIに出すと分かりやすい)
  • agent_message_chunk: テキストのチャンク(逐次追記)
  • tool_call / tool_call_update: ツール実行の開始/進捗/完了(Tool Calls

ターンの終了はsession/promptのレスポンスで分かります。

1
2
3
4
5
6
7
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "stopReason": "end_turn"
  }
}

session/request_permission(許可UIは必須)

エージェントはツールを実行する前に、クライアントへ許可要求を投げる場合があります(Tool CallsのRequesting Permission)。

ここがACP実装の一番の落とし穴で、重要な観点です。

注意点は、これが「エージェント→クライアント」の双方向JSON-RPCリクエストだという点です。つまりエディタ側は、単にエージェントへ送るクライアントではなく、エージェントからのJSON-RPCリクエストを受けて応答するサーバでもあります。

  • id付きのsession/request_permissionを受け取ったら、クライアントは必ず同じidでレスポンスを返します
  • ここを返さないと、エージェントは許可待ちで止まり、session/updateも進まなくなります
  • 実装上は「stdoutのストリームを読みながら、通知・レスポンス・逆方向リクエストをディスパッチする」構造が必要です
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "session/request_permission",
  "params": {
    "sessionId": "sess_abc123def456",
    "toolCall": {
      "toolCallId": "call_001"
    },
    "options": [
      {
        "optionId": "allow-once",
        "name": "Allow once",
        "kind": "allow_once"
      },
      {
        "optionId": "reject-once",
        "name": "Reject",
        "kind": "reject_once"
      }
    ]
  }
}

ユーザーが選択した結果を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "outcome": {
      "outcome": "selected",
      "optionId": "allow-once"
    }
  }
}

最小のクライアント実装例(stdio + JSON-RPC)

以下は、エディタ側がACPエージェントを起動して、initializesession/newsession/prompt を送りつつ、双方向リクエストも最低限捌くための骨組みです。動作確認は、ACP対応のエージェントに置き換えて使ってください。

前提:

  • Python 3.11以上
  • エージェントの実行ファイルが用意されている
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
import json
import subprocess
from pathlib import Path

def send(proc, payload):
    proc.stdin.write(json.dumps(payload) + "\n")
    proc.stdin.flush()

def recv(proc):
    return json.loads(proc.stdout.readline())

def send_result(proc, msg_id, result):
    send(proc, {"jsonrpc": "2.0", "id": msg_id, "result": result})

def handle_request_from_agent(proc, msg):
    method = msg.get("method")
    msg_id = msg.get("id")
    params = msg.get("params") or {}

    if method == "session/request_permission":
        # TODO: show UI and let the user decide.
        # Here we always allow once to keep the example runnable.
        send_result(proc, msg_id, {"outcome": {"outcome": "selected", "optionId": "allow-once"}})
        return

    if method == "fs/read_text_file":
        # Minimal, safe behavior: return empty content.
        # A real editor should return the current buffer (including unsaved edits).
        send_result(proc, msg_id, {"content": ""})
        return

    if method == "fs/write_text_file":
        # Minimal, safe behavior: write to disk.
        path = Path(params["path"])
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(params["content"], encoding="utf-8")
        send_result(proc, msg_id, None)
        return

    # Unknown request: respond with JSON-RPC error.
    send(proc, {
        "jsonrpc": "2.0",
        "id": msg_id,
        "error": {"code": -32601, "message": f"Method not found: {method}"}
    })

proc = subprocess.Popen(
    ["/path/to/agent"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    text=True,
)

send(proc, {
    "jsonrpc": "2.0",
    "id": 0,
    "method": "initialize",
    "params": {
        "protocolVersion": 1,
        "clientCapabilities": {
            "fs": {"readTextFile": True, "writeTextFile": True},
            "terminal": True
        },
        "clientInfo": {"name": "my-editor", "title": "My Editor", "version": "0.1.0"}
    }
})
init_res = recv(proc)

send(proc, {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "session/new",
    "params": {
        "cwd": "/home/user/project",
        "mcpServers": []
    }
})
session_res = recv(proc)
session_id = session_res["result"]["sessionId"]

send(proc, {
    "jsonrpc": "2.0",
    "id": 2,
    "method": "session/prompt",
    "params": {
        "sessionId": session_id,
        "prompt": [{"type": "text", "text": "READMEを要約して"}]
    }
})

while True:
    msg = recv(proc)

    # Requests from the Agent to the Client (bidirectional JSON-RPC)
    if msg.get("method") and msg.get("id") is not None:
        handle_request_from_agent(proc, msg)
        continue

    # Notifications from the Agent (stream updates)
    if msg.get("method") == "session/update":
        update = msg["params"]["update"]
        if update.get("sessionUpdate") == "agent_message_chunk":
            print(update["content"]["text"], end="")

    # Response to our session/prompt
    if msg.get("id") == 2:
        break

この例では、session/update をストリームとして受け取り、テキストのチャンクをそのまま表示しています。UI上ではこのストリーミングを「生成中のメッセージ」として扱うと自然です。

また、session/request_permissionfs/*のような「エージェント→クライアント」リクエストも最低限処理しています。ここを実装しないと、エージェントが許可待ちで止まります。

動作確認用: 最小のモックエージェント(stdio)

エディタ統合を始めるとき、「本物のエージェント」と繋ぐ前に、まずは自分のJSON-RPCディスパッチとUIを確認したくなります。 そのための最小モックを置いておきます。stdoutにはACPメッセージ以外を出さず、ログはstderrに流します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
import json
import sys


def send(payload):
    sys.stdout.write(json.dumps(payload) + "\n")
    sys.stdout.flush()


def log(msg):
    sys.stderr.write(msg + "\n")
    sys.stderr.flush()


session_id = "sess_mock"

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue

    msg = json.loads(line)
    method = msg.get("method")
    msg_id = msg.get("id")
    params = msg.get("params") or {}

    if method == "initialize":
        send({
            "jsonrpc": "2.0",
            "id": msg_id,
            "result": {
                "protocolVersion": 1,
                "clientCapabilities": params.get("clientCapabilities", {}),
            },
        })
        continue

    if method == "session/new":
        session_id = "sess_mock_001"
        send({
            "jsonrpc": "2.0",
            "id": msg_id,
            "result": {"sessionId": session_id},
        })
        continue

    if method == "session/prompt":
        # Stream a plan
        send({
            "jsonrpc": "2.0",
            "method": "session/update",
            "params": {
                "sessionId": session_id,
                "update": {
                    "sessionUpdate": "plan",
                    "entries": [
                        {"content": "Echo the prompt", "priority": "high", "status": "pending"}
                    ],
                },
            },
        })

        # Stream message chunks
        send({
            "jsonrpc": "2.0",
            "method": "session/update",
            "params": {
                "sessionId": session_id,
                "update": {
                    "sessionUpdate": "agent_message_chunk",
                    "content": {"type": "text", "text": "received: "},
                },
            },
        })

        prompt = params.get("prompt", [])
        text = ""
        for block in prompt:
            if block.get("type") == "text":
                text += block.get("text", "")

        send({
            "jsonrpc": "2.0",
            "method": "session/update",
            "params": {
                "sessionId": session_id,
                "update": {
                    "sessionUpdate": "agent_message_chunk",
                    "content": {"type": "text", "text": text},
                },
            },
        })

        # End turn
        send({
            "jsonrpc": "2.0",
            "id": msg_id,
            "result": {"stopReason": "end_turn"},
        })
        continue

    log(f"Unhandled method: {method}")
    if msg_id is not None:
        send({
            "jsonrpc": "2.0",
            "id": msg_id,
            "error": {"code": -32601, "message": f"Method not found: {method}"},
        })

このモックをpython acp_mock_agent.pyとして起動し、上のクライアント例の/path/to/agentをPythonに差し替えれば、ストリーミング表示の配線確認ができます。

エディタ統合でよく実装するクライアントメソッド

ACPは「ツール呼び出しの実行本体」をエージェント側に寄せつつ、エディタならではの体験(未保存バッファ、差分表示、許可UIなど)をクライアント側のメソッドで実現します。

  • session/request_permission: 許可UI(必須級)
  • fs/read_text_file / fs/write_text_file: エディタバッファの読み書き(File System
  • terminal/*: ターミナルの起動/出力取得(Terminals

まずはsession/request_permissionsession/updateの表示だけ入れて、ファイル操作系は後から足すのが安全です。

主要言語ごとのクライアントライブラリ

ゼロからJSON-RPCとスキーマを組むのは地味に面倒なので、まずは公式/コミュニティのライブラリを使うのが近道です。

TypeScript / JavaScript

クライアント側はClientSideConnection、エージェント側はAgentSideConnectionを使って接続を張る設計です(詳細はTypeScript library )。

Python

Pydanticモデルとasyncの土台が入っていて、クライアント/エージェントの両方に使えます(Python library )。

Rust

Client/Agent traitを実装して両側を作れる設計です(Rust library )。

それ以外(コミュニティ)

公式に載っているコミュニティ実装もあります(Community libraries )。例えば:

コミュニティ実装はメンテ状況が揺れやすいので、エディタ本体に組み込む場合は、最初にリリース頻度と互換性ポリシーを確認しておくと安全です。

Claude CodeをターゲットにするならCCSDKを使うべき?

エディタ開発者が「Claude Codeをエージェントとして使いたい」だけなら、基本的にはCCSDKをエディタに直組みするより、ACPクライアントを実装する方が筋が良いです。

理由はシンプルで、ACPはエディタ統合のためのインターフェイス(セッション、ストリーミング、許可UI、差分など)がすでに規格化されていて、Claude Code側もACPエコシステムに載れるようにアダプタ経由で提供されているからです。

  • 例: Zedは「Claude CodeをSDKアダプタで統合し、ACPクライアントから使える」と明記しています(Claude Code - ACP Agent | Zed )。

一方で、CCSDK(現在はClaude Agent SDK として公開されています)を使う選択肢もあります。ただしこれは「自分がエディタ兼クライアント実装も全部持つ」方向です。

  • CCSDKを使うのが向くケース: ACPの枠を超えた独自UI/機能が必要、あるいはエディタが単一エージェント専用で相互運用性を捨てられる
  • ACPクライアントを作るのが向くケース: 将来のエージェント乗り換えを楽にしたい、他のACPエージェントも同じ統合で扱いたい

どちらにしても、エディタ側が実装すべき中心は「ストリーミング表示」と「許可UI」です。そこができると、エージェントが何であれ体験は一定になります。

まとめ: まずどこを読むか

ACPは「エディタとエージェントを分離して、選択肢を広げる」ための標準です。今後、エージェント実装や対応エディタが増えてくる前提で読む価値があります。

次のアクションとしては、Overview → Initialization → Session Setup → Prompt Turn の順で読むと、そのまま実装に落とせます。

自分がエディタ統合を始めるなら、次の順で作業します。

  1. stdio JSON-RPCの入出力を安定させる(ログはstderr、stdoutはプロトコル専用)
  2. initialize / session/new / session/prompt を通す
  3. session/updateのうちagent_message_chunkを表示できるようにする
  4. session/request_permissionのUIを作る
  5. tool_call/tool_call_updateとdiff表示に対応する
  6. 必要になったらfs/*terminal/*をcapabilitiesに載せる

参考リンク