ask-acp
The Agent Client Protocol (ACP) for Ruby — JSON-RPC 2.0 over stdio for agent–client communication. ACP is the wire protocol coding agents speak to editors, CLIs, and other clients: a client sends JSON-RPC requests on stdin, the agent streams events and responses on stdout. ask-acp gives you all three sides of that relationship — a client that drives an ACP-speaking coding agent as a subprocess, a server base class that makes your own Ruby agent speak ACP, and a replay client that re-plays recorded interactions for fast, deterministic testing.
ACP works in two directions:
- Drive a coding agent — spawn Codex, OpenCode, or any other ACP agent from Ruby, create sessions, stream prompts and tool calls, cancel turns.
- Be a coding agent — subclass
Ask::ACP::Serverand any ACP client (an editor integration, a CLI wrapper, your own tooling) can drive your Ruby agent without knowing it’s Ruby.
Both directions are protocol, not framework — there is no shared agent loop and no ask-rb dependency in the gem itself.
gem "ask-acp"
Quick Start
The fastest way in is the replay client: it reads a fixture of recorded JSON-RPC messages instead of spawning a subprocess, so you get the full session flow with instant, deterministic responses. This example writes a small fixture, then drives a session end to end:
require "ask-acp"
File.write("opencode.jsonl", <<~JSONL)
{"response":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"OpenCode","version":"1.18.3"}}}}
{"response":{"jsonrpc":"2.0","id":2,"result":{"session":{"id":"sess_1","status":"running","createdAt":"2026-08-10T10:00:00Z"}}}}
{"notification":{"jsonrpc":"2.0","method":"text","params":{"sessionId":"sess_1","content":"I'll list the files first."}}}
{"notification":{"jsonrpc":"2.0","method":"turn_complete","params":{"sessionId":"sess_1"}}}
{"response":{"jsonrpc":"2.0","id":3,"result":{"status":"completed"}}}
JSONL
client = Ask::ACP::ReplayClient.new(fixture_path: "opencode.jsonl")
client.start
info = client.initialize!(client_name: "my-app", client_version: "0.1.0")
info
# => {"protocolVersion" => 1, "agentInfo" => {"name" => "OpenCode", "version" =>
# "1.18.3"}}
session = client.session_new(cwd: ".")
session
# => {id: "sess_1", status: "running", created_at: "2026-08-10T10:00:00Z"}
events = []
client.on_notification { |event| events << event["method"] }
client.session_prompt(session[:id], "What files are in this directory?")
events
# => ["text", "turn_complete"]
The API is identical for a live agent — only the fixture is replaced by a real subprocess. More on that next.
Talk to a real coding agent
Ask::ACP::Client spawns the agent CLI with Open3 and speaks ACP over stdio. The command is the one that puts the agent into ACP mode — for example Codex’s codex acp or OpenCode’s opencode acp:
require "ask-acp"
client = Ask::ACP::Client.new(command: ["codex", "acp"])
client.start
client.initialize!(client_name: "my-app", client_version: "0.1.0")
session = client.session_new(cwd: "/path/to/project")
# Stream prompt events as they arrive
client.session_prompt(session[:id], "Explain this codebase") do |event|
puts "#{event[:method]}: #{event[:params].inspect}"
end
client.stop
The initialize handshake returns the agent’s capabilities and any authentication methods it requires (authMethods) — if the agent needs a token before it will start a session, see Authentication & configuration below.
Agents that require auth are also covered by ask-coding-providers, the registry of coding-agent adapters (:acp, :ask_agent, :claude, :codex) that knows how to spawn and authenticate each agent.
Write your own ACP agent
Ask::ACP::Server is the other direction: a base class that reads JSON-RPC from stdin, dispatches requests to your handlers, and writes responses back. Subclass it, implement the handle_* methods you care about, and call run.
The full round trip — a Ruby agent as a subprocess, driven by a real Ask::ACP::Client — looks like this:
require "ask-acp"
# The agent script: a small ACP server, run as its own process.
agent = <<~RUBY
require "ask-acp"
class DocsAgent < Ask::ACP::Server
def handle_session_new(params)
{ session: { id: "sess_docs", status: "running", createdAt: "2026-08-10T10:00:00Z" } }
end
def handle_session_prompt(params)
send_text_delta(params["sessionId"], "Hello from my Ruby agent!")
send_event("turn_complete", { sessionId: params["sessionId"] })
{ status: "completed" }
end
end
DocsAgent.new.run
RUBY
File.write("docs_agent.rb", agent)
# Drive it from a real client, just like a Codex or OpenCode subprocess.
client = Ask::ACP::Client.new(command: [RbConfig.ruby, "-I", $LOAD_PATH.join(":"), "docs_agent.rb"])
client.start
info = client.initialize!(client_name: "my-app", client_version: "0.1.0")
info
# => {"protocolVersion" => 1,
# "capabilities" => {},
# "serverInfo" => {"name" => "ask-acp", "version" => "0.1.2"}}
session = client.session_new(cwd: ".")
session
# => {id: "sess_docs", status: "running", created_at: "2026-08-10T10:00:00Z"}
events = []
result = client.session_prompt(session[:id], "Hello!") { |event| events << event[:method] }
events
# => ["text", "turn_complete"]
result
# => {"status" => "completed"}
client.stop
Every handle_* method receives the request params (string keys) and its return value becomes the response result. The defaults are all reasonable to override:
| Handler | Default response |
|---|---|
handle_initialize(params) | Protocol version, capabilities, server name/version |
handle_session_new(params) | New random sessionId, status "running" |
handle_session_load(params) | The requested session id, status "running" |
handle_session_list(params) | { sessions: [] } |
handle_session_resume(params) | The requested session id, status "running" |
handle_session_close(params) | {} |
handle_session_prompt(params) | One text delta, then turn_complete, then { status: "completed" } |
handle_session_cancel(params) | {} |
During a prompt you stream progress before the final response with send_text_delta(session_id, content) and send_event(method, params) — both write JSON-RPC notifications to stdout. A client that never calls initialize! or sends an unknown method gets a proper JSON-RPC error back: -32600 for handler exceptions, -32601 for unknown methods.
The session lifecycle
Sessions are the unit of work in ACP: you create one in a working directory, send prompts into it, and can list, load, resume, fork, and close it. ask-acp normalizes the raw JSON-RPC result into a consistent hash for the session methods:
require "ask-acp"
File.write("sessions.jsonl", <<~JSONL)
{"response":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}}}
{"response":{"jsonrpc":"2.0","id":2,"result":{"sessions":[{"id":"sess_1","status":"running","createdAt":"2026-08-10T10:00:00Z"},{"id":"sess_2","status":"completed","createdAt":"2026-08-09T09:00:00Z"}]}}}
{"response":{"jsonrpc":"2.0","id":3,"result":{"session":{"id":"sess_1","status":"running","createdAt":"2026-08-10T10:00:00Z"}}}}
{"response":{"jsonrpc":"2.0","id":4,"result":{"session":{"id":"sess_1","status":"running","createdAt":"2026-08-10T10:00:00Z"}}}}
{"response":{"jsonrpc":"2.0","id":5,"result":{}}}
JSONL
client = Ask::ACP::ReplayClient.new(fixture_path: "sessions.jsonl")
client.start
client.initialize!(client_name: "my-app", client_version: "0.1.0")
list = client.session_list(cwd: ".")
list
# => [{id: "sess_1", status: "running", created_at: "2026-08-10T10:00:00Z"},
# {id: "sess_2", status: "completed", created_at: "2026-08-09T09:00:00Z"}]
loaded = client.session_load("sess_1")
loaded
# => {id: "sess_1", status: "running", created_at: "2026-08-10T10:00:00Z"}
resumed = client.session_resume("sess_1")
resumed
# => {id: "sess_1", status: "running", created_at: "2026-08-10T10:00:00Z"}
client.session_close("sess_1")
client.running?
# => true
The full method set, with the params each sends:
| Method | Params | Returns |
|---|---|---|
session_new(cwd:, model:, tools:) | cwd, optional model, optional tools | normalized session |
session_load(session_id) | sessionId | normalized session |
session_list(cwd:) | optional cwd | array of normalized sessions |
session_resume(session_id) | sessionId | normalized session |
session_fork(session_id) | sessionId | normalized session |
session_close(session_id) | sessionId | raw result |
A normalized session is always { id:, status:, created_at: } — status is one of completed, failed, cancelled, in_progress.
Streaming prompt events
session_prompt is the workhorse: it sends a prompt and streams the agent’s progress as events before the final response. The prompt can be a plain string — ask-acp wraps it in a ContentBlock array for you — or an explicit array of content blocks.
The streamed events are the PROMPT_EVENTS:
| Event | Meaning |
|---|---|
text | A text delta from the agent |
tool_use | The agent wants to call a tool |
tool_result | A tool call finished |
turn_complete | The turn finished successfully |
turn_failed | The turn ended in an error |
session/update | Session state changed |
session/request_permission | The agent needs approval to act |
session/elicitation | The agent needs input from the user |
Both Client and ReplayClient accept the block form — it registers a temporary notification handler that receives { method:, params: } for each event and is removed when the prompt finishes:
require "ask-acp"
File.write("tool_events.jsonl", <<~JSONL)
{"response":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}}}
{"notification":{"jsonrpc":"2.0","method":"tool_use","params":{"sessionId":"sess_1","toolUse":{"id":"use_1","name":"read_file","input":{"path":"notes.md"}}}}}
{"notification":{"jsonrpc":"2.0","method":"tool_result","params":{"sessionId":"sess_1","toolUseId":"use_1","result":"# Notes\\n- one\\n- two"}}}
{"notification":{"jsonrpc":"2.0","method":"turn_complete","params":{"sessionId":"sess_1"}}}
{"response":{"jsonrpc":"2.0","id":2,"result":{"status":"completed"}}}
JSONL
client = Ask::ACP::ReplayClient.new(fixture_path: "tool_events.jsonl")
client.start
client.initialize!(client_name: "my-app", client_version: "0.1.0")
seen = []
client.session_prompt("sess_1", "Read notes.md") { |event| seen << event[:method] }
seen
# => ["tool_use", "tool_result", "turn_complete"]
For everything else the agent sends you — file access, permission requests, terminal control — use on_notification to register a permanent handler (see below).
While a prompt is running you can interrupt it from another thread with session_cancel(session_id) — the agent decides what to do with the request, and the prompt call will finish with whatever status it chooses.
Handling agent → client methods
ACP is bidirectional: the agent can call client methods too — file access, permission requests, terminal control. These arrive as notifications and land in your on_notification handlers:
require "ask-acp"
File.write("permission.jsonl", <<~JSONL)
{"response":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}}}
{"notification":{"jsonrpc":"2.0","method":"session/request_permission","params":{"sessionId":"sess_1","toolName":"bash","description":"Run `rm -rf /tmp/scratch`"}}}
{"response":{"jsonrpc":"2.0","id":2,"result":{"status":"completed"}}}
JSONL
client = Ask::ACP::ReplayClient.new(fixture_path: "permission.jsonl")
client.start
client.initialize!(client_name: "my-app", client_version: "0.1.0")
requests = []
client.on_notification do |event|
requests << event["params"] if event["method"] == "session/request_permission"
end
client.session_prompt("sess_1", "Clean up the scratch directory")
requests
# => [{"sessionId" => "sess_1", "toolName" => "bash", "description" => "Run `rm -rf
# /tmp/scratch`"}]
The full set of client methods the agent may send:
| Method | When the agent sends it |
|---|---|
fs/read_text_file | It needs to read a file from the client’s filesystem |
fs/write_text_file | It wants to write a file |
session/update | Session state changed |
session/request_permission | It needs approval before acting |
session/elicitation | It needs input from the user |
session/elicitation/complete | An elicitation round finished |
terminal/create | It wants a terminal |
terminal/output | Terminal output to relay |
terminal/wait_for_exit | It’s waiting on a terminal process |
terminal/kill / terminal/release | Terminal lifecycle |
A real client answers these — e.g. running fs/read_text_file and returning the file contents, or surfacing a permission request to the user — so the agent can keep working.
Authentication & configuration
If the agent requires authentication, the initialize result carries authMethods — the prompt for a token is yours to build. Once you have it, send it with authenticate:
require "ask-acp"
client = Ask::ACP::Client.new(command: ["opencode", "acp"])
client.start
info = client.initialize!(client_name: "my-app", client_version: "0.1.0")
if info["authMethods"] && !info["authMethods"].empty?
token = prompt_for_token(info["authMethods"]) # your UI, your call
client.authenticate(token: token, scheme: "bearer")
end
client.session_new(cwd: ".")
Per-session configuration is three methods, each a single JSON-RPC round trip:
| Method | Params | Purpose |
|---|---|---|
session_set_config_option(session_id, key, value) | sessionId, key, value | Set any agent config option |
session_set_mode(session_id, mode) | sessionId, mode | Switch the agent’s mode (e.g. plan vs. build) |
session_set_model(session_id, provider:, model:) | sessionId, provider, model | Switch the model for a session |
Call them after session_new, before the next session_prompt. What options and modes exist is up to the agent — the protocol just carries them.
Testing with ReplayClient
ReplayClient is the same interface as Client minus the subprocess — it reads a fixture of newline-delimited JSON records and answers requests in order, instantly. It exists for deterministic tests and docs, and it makes your agent code testable without an API key, a network, or even an installed agent.
The fixture format is one record per line, three kinds:
require "ask-acp"
File.write("hello.jsonl", <<~JSONL)
{"request":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}}
{"response":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}}}
{"notification":{"jsonrpc":"2.0","method":"text","params":{"content":"Hi!"}}}
JSONL
client = Ask::ACP::ReplayClient.new(fixture_path: "hello.jsonl")
client.start
client.initialize!(client_name: "my-app", client_version: "0.1.0")
client.running?
# => true
{"request": ...}— a request the client sent (informational; recorded for review){"response": ...}— a response the agent sent; answers the next pending request{"notification": ...}— an async notification from the agent; delivered toon_notificationhandlers when a prompt is streamed
Because the client responds in record order, a fixture can encode an entire session — including tricky sequences like a permission request mid-prompt — and the test re-runs it identically every time.
To record a fixture from a real agent, use the record_acp executable (installed with the gem): record_acp opencode fixtures/session.jsonl — it spawns the agent, runs a standard session flow, and writes every message to a fixture file.
Errors and timeouts
ask-acp raises Ask::ACP::Error (and its subclass Ask::ACP::TimeoutError) for protocol-level problems:
- Calling any method before
startraisesAsk::ACP::Error(“ACP client not started. Call #start first.”) - Calling a session method before
initialize!raisesAsk::ACP::Error(“ACP not initialized.”) - JSON-RPC error responses become
Ask::ACP::Errorwith the code and message from the agent. - A request that gets no response within
request_timeoutseconds (default 30) raisesAsk::ACP::TimeoutError.
Spawning a command that doesn’t exist raises at start:
require "ask-acp"
client = Ask::ACP::Client.new(command: ["definitely-not-a-real-agent"])
begin
client.start
rescue => e
error = e
end
error.class
# => Errno::ENOENT
And a silent agent triggers the timeout:
require "ask-acp"
agent = <<~RUBY
require "ask-acp"
class SlowAgent < Ask::ACP::Server
def handle_initialize(params)
sleep 5
end
end
SlowAgent.new.run
RUBY
File.write("slow_agent.rb", agent)
client = Ask::ACP::Client.new(
command: [RbConfig.ruby, "-I", $LOAD_PATH.join(":"), "slow_agent.rb"],
request_timeout: 0.5
)
client.start
begin
client.initialize!(client_name: "my-app", client_version: "0.1.0")
rescue => e
error = e
end
error.class
# => Ask::ACP::TimeoutError
client.stop
stop closes stdin, terminates the subprocess, and fails every still-pending request with Ask::ACP::Error("process exited").
Protocol reference
The Protocol module holds the constants and message helpers:
require "ask-acp"
Ask::ACP::Protocol::PROTOCOL_VERSION
# => 1
Ask::ACP::Protocol::AGENT_METHODS[:session_new]
# => "session/new"
Ask::ACP::Protocol::STATUSES[:completed]
# => "completed"
| Constant | Contents |
|---|---|
PROTOCOL_VERSION | 1 (v0.11.3 schema) |
AGENT_METHODS | All client → agent methods (initialize, authenticate, logout, session/*) |
CLIENT_METHODS | All agent → client methods (fs/*, session/elicitation, terminal/*) |
PROMPT_EVENTS | The streamed prompt events (text, tool_use, tool_result, turn_complete, …) |
STATUSES | completed, failed, cancelled, in_progress |
Plus builders for the wire format if you ever need them: build_request, build_notification, build_response, build_error, parse, and serialize.
ACP, app-server, or MCP?
Three protocol gems, three jobs — they don’t overlap much:
| You want to… | Use |
|---|---|
| Drive a coding agent (Codex, OpenCode) from Ruby | ask-acp client |
| Make your Ruby agent a first-class citizen of ACP clients (editors, CLIs) | ask-acp server |
| Expose an ask-rb agent to app-server clients (IDE extensions, chat UIs) | ask-app-server |
| Discover and call tools on an MCP server — or expose your tools to MCP clients | ask-mcp |
| Know how to spawn and authenticate each coding agent | ask-coding-providers |
ACP and app-server are both JSON-RPC 2.0 over stdio, but they serve different relationships: app-server is a service interface for your agent (sessions, turn lifecycle, approvals), while ACP is the agent-side protocol coding agents speak with their clients. MCP is orthogonal — it’s tool discovery and invocation, not sessions.