- Book: Event-Driven Architecture Pocket Guide
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
When Zed wanted third-party agents inside its editor in August 2025, the team wrote that they needed "a more structured way of communicating than ANSI escape codes". Parsing a terminal UI to guess what an agent is doing breaks every time the agent redraws its screen.
Their answer was the Agent Client Protocol, ACP for short: an Apache-licensed set of JSON-RPC methods, announced on August 27, 2025, with Google's Gemini CLI as "the initial reference implementation" (Zed). Five months later, on January 28, 2026, Zed, working with JetBrains, opened the ACP Registry, so an agent registered once shows up in every editor that speaks the protocol.
You can send Gemini CLI the same four requests an editor sends, and read every line it answers with, in about 70 lines of Python.
What travels over the pipe
ACP is JSON-RPC 2.0. The editor (the client) launches the agent as a subprocess, writes messages to its stdin and reads messages from its stdout. Each message is one line: the transport spec says messages are "delimited by newlines" and "MUST NOT" contain embedded newlines. Logs, if any, go to stderr. Nothing else is allowed on stdout.
There are two kinds of message (overview). Methods are request and response pairs, matched by id. Notifications are one-way and get no reply. The traffic goes both ways: the editor calls the agent (initialize, session/new, session/prompt), and the agent calls the editor back (session/request_permission, and optionally fs/read_text_file, fs/write_text_file and the terminal/* methods, if the editor said it supports them).
A prompt turn has a fixed shape. The editor sends session/prompt. The agent streams session/update notifications while it works, may stop to ask permission, and ends the turn by answering the original session/prompt request with a stop reason. Until that answer arrives, the turn is still running.
Start Gemini CLI in ACP mode
Install Gemini CLI with npm install -g @google/gemini-cli (README). The flag is --acp. Older guides use --experimental-acp, and the current source still accepts it, with the help text "deprecated, use --acp instead" (config.ts). The ACP mode docs also use gemini --acp, though the CLI reference page still lists only the old flag. Run gemini --help on your version to be sure.
One catch before you start. The Gemini CLI README says: "Unpaid tier and Google One users: Gemini CLI was replaced by Antigravity CLI on June 18th, 2026." The script below signs in with a Gemini API key, which is one of the methods Gemini CLI still offers. If your key is on the unpaid tier, check that it still works with Gemini CLI before you go further.
Here is the whole client. It prints every line it sends (>>) and receives (<<), and when the agent asks for permission, it asks you:
# acp_by_hand.py: talk to `gemini --acp` over stdio
import json
import os
import subprocess
import sys
KEY = os.environ["GEMINI_API_KEY"]
agent = subprocess.Popen(
["gemini", "--acp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
bufsize=1,
)
def send(msg):
line = json.dumps(msg)
print(">>", line.replace(KEY, "<key>"))
agent.stdin.write(line + "\n")
agent.stdin.flush()
def answer(req):
options = req["params"]["options"]
for i, opt in enumerate(options):
print(f" [{i}] {opt['name']} ({opt['kind']})")
pick = options[int(input("pick an option: "))]
send({"jsonrpc": "2.0", "id": req["id"], "result": {
"outcome": {"outcome": "selected",
"optionId": pick["optionId"]}}})
def wait_for(req_id):
for line in agent.stdout:
msg = json.loads(line)
print("<<", line.rstrip())
if msg.get("method") == "session/request_permission":
answer(msg)
elif msg.get("id") == req_id and "method" not in msg:
if "error" in msg:
sys.exit(msg["error"])
return msg["result"]
sys.exit("agent closed stdout")
json.dumps writes one line with no newlines inside it, which is exactly what the transport asks for. The "method" not in msg check matters. The agent picks its own ids for the requests it sends you, so a permission request can carry the same id as one of yours. A request has a method and a response doesn't, so that's how you tell them apart.
The four requests an editor sends
Append this to the same file. Run it in a scratch folder such as ~/code/acp-scratch, because the prompt asks the agent to create a file there.
send({"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {
"fs": {"readTextFile": False,
"writeTextFile": False},
"terminal": False}}})
wait_for(0)
send({"jsonrpc": "2.0", "id": 1, "method": "authenticate",
"params": {"methodId": "gemini-api-key",
"_meta": {"api-key": KEY}}})
wait_for(1)
send({"jsonrpc": "2.0", "id": 2, "method": "session/new",
"params": {"cwd": os.getcwd(), "mcpServers": []}})
sid = wait_for(2)["sessionId"]
send({"jsonrpc": "2.0", "id": 3, "method": "session/prompt",
"params": {"sessionId": sid, "prompt": [
{"type": "text",
"text": "Create hello.txt containing: hi"}]}})
print("stop reason:", wait_for(3)["stopReason"])
agent.stdin.close()
agent.wait()
What each one does, from the spec and Gemini CLI's source:
-
initializeagrees on a protocol version (the spec's example uses1) and swaps capabilities. Here the client says it can't read files, write files or run terminals for the agent. Gemini CLI's file service then falls back to the local disk (acpFileSystemService.ts). In return, Gemini CLI advertisesloadSession, image, audio and embedded-context prompts, and MCP over HTTP and SSE, plus four sign-in methods: Log in with Google, Gemini API key, Vertex AI and AI API Gateway (acpRpcDispatcher.ts). -
authenticatepicks one. The id for the API key method isgemini-api-key(contentGenerator.ts), and the handler reads the key from_meta["api-key"]. It also saves the method you picked to your Gemini CLI user settings. -
session/newopens a conversation.cwd"MUST be an absolute path" (session setup), andmcpServersis where an editor would hand the agent its own tools. The result carries asessionId. -
session/promptsends your message as a list of content blocks. A slash command travels the same way, as plain text like/web agent client protocol(slash commands).
I haven't run this script against a live Gemini account, so I'm not showing output here. Everything below is the message shape from the spec and Gemini CLI's source, which is what you should see scroll past.
Decoding what comes back
While the turn runs, the agent sends session/update notifications. Each one has params.sessionId and params.update, and update.sessionUpdate names the variant (prompt turn). This is the spec's own example of a tool call starting:
{"jsonrpc":"2.0","method":"session/update","params":{
"sessionId":"sess_abc123def456",
"update":{"sessionUpdate":"tool_call",
"toolCallId":"call_001",
"title":"Analyzing Python code",
"kind":"other","status":"pending"}}}
The variants Gemini CLI's session code sends are agent_message_chunk (the reply, streamed), agent_thought_chunk (its reasoning), user_message_chunk, tool_call, tool_call_update, usage_update and available_commands_update, which lists the slash commands it accepts (acpSession.ts).
A tool call gets a kind an editor can pick an icon from: read, edit, delete, move, search, execute, think, fetch, switch_mode or other. Its status moves through pending, in_progress, completed or failed (tool calls). A tool_call_update carries only the fields that changed.
When the agent wants to write hello.txt, it stops and calls you with session/request_permission. For a file edit, Gemini CLI builds the options from its own confirmation outcomes (acpUtils.ts, tools.ts):
name |
optionId |
kind |
|---|---|---|
| Allow for this session | proceed_always |
allow_always |
| Allow | proceed_once |
allow_once |
| Reject | cancel |
reject_once |
The "Allow for this session" row is left out if always-allow is disabled in Gemini CLI's config, and a fourth "all future sessions" option appears only when permanent approval is turned on. Your script answers with {"outcome": {"outcome": "selected", "optionId": "proceed_once"}} under the request's own id. If you cancel a turn with the session/cancel notification, the spec says the client MUST answer any open permission request with {"outcome": {"outcome": "cancelled"}}.
The turn ends when the response to request 3 arrives: {"result": {"stopReason": "end_turn"}} in the normal case. The other stop reasons are max_tokens, max_turn_requests, refusal and cancelled. That is one whole turn. An editor that advertises file and terminal support also gets fs/* and terminal/* calls; apart from those, Zed's agent panel or a JetBrains IDE reads the same kinds of messages your script does.
The other design: the agent's own terminal
I build Hermes IDE, so weigh this section with that in mind. Hermes takes the opposite route. Every agent, Gemini CLI included, runs its own interface in a real terminal, "exactly as it would anywhere else, so a vendor's new commands and modes work in Hermes the day they ship" (README). Nothing gets translated, so nothing waits for a translation to catch up.

Hermes 2.3.4: Codex on orders-types and Claude Code on orders-api, each in its own terminal and hermes/ worktree, with status, context use, cost and branch in the sidebar.
What Hermes adds sits around those terminals: a branch and worktree per task, one status per session (which says when it's a guess), an inbox of agents waiting on you, and every agent turn saved so you can diff or restore it on its own. It never answers a permission prompt for you and never types into a terminal on its own. When Gemini CLI asks to write hello.txt in Hermes, you answer in Gemini CLI's own prompt, not in a Hermes button.
Hermes's design record, ADR 003, names ACP as the kind of protocol it would build on. A structured view gets built only when a vendor offers a documented, stable machine protocol ("the Claude Agent SDK stream, ACP, the Codex app server"), the view adds something the terminal can't, and you opt in. Today that view exists only for Claude, as the optional Agent view.
What each design gives up
ACP gives an editor typed events. A permission request arrives with a tool kind and a fixed list of options, so the editor can draw its own approve button. File access can go through the editor, so the agent sees what the editor allows. And one integration reaches every client in the registry, which lists agents such as Claude Code, Codex CLI, GitHub Copilot CLI, OpenCode and Gemini CLI (Zed).
What ACP gives up is the agent's own interface. The editor shows what the protocol carries and what the agent or its adapter maps into it. Claude Code and Codex don't speak ACP out of the box; Zed reaches both through adapters it built (Claude Code, Codex). Zed's Claude Code integration is "an adapter that wraps Claude Code's SDK", and at its launch in September 2025 Zed noted that "many built-in slash commands are not yet supported by the SDK" (Zed). That gap may have closed since, but any adapter can fall behind the CLI it wraps.
The terminal route gives up the typed stream. Hermes can't draw tool-call cards for Gemini CLI, its status for a terminal agent is sometimes inferred (and marked as such), and you approve each tool call inside the agent's own UI. It keeps the vendor's exact interface, on the day the vendor ships a change.
For one native UI across many agents, ACP is the better design. A terminal wins when you want each agent exactly as its vendor ships it, side by side. Which way do you run yours?
If this was useful
Strip the editor away and ACP is a message system: ids that pair a reply with its request, notifications nobody acknowledges, a turn that is over only when its response lands, and two peers that both send requests. The Event-Driven Architecture Pocket Guide covers the same problems at service scale: correlation, retries, idempotency, sagas and the outbox. If you want to try the terminal route, Hermes IDE is free to download, source-available under BSL 1.1, runs on macOS, Windows and Linux, and is on GitHub. It is not affiliated with Nous Research, Google, Zed, JetBrains or any agent vendor.

Top comments (0)