acp_sdk_async
Ruby SDK for the Agent Client Protocol (ACP): typed schema models generated from the official schema.json, a JSON-RPC 2.0 connection over NDJSON, client- and agent-side wrappers, and helpers for spawning agents over stdio.
Requires Ruby 3.2+ and runs on async, the same cooperative scheduler used by Falcon. The public API is blocking (client.prompt(...)) like a normal Ruby method; inside a reactor those calls yield to other tasks instead of occupying a thread.
Inspired by the official Python ACP SDK.
Installation
gem "acp_sdk_async"From git:
gem "acp_sdk_async", git: "git@github.com:nutsoriginal/acp_sdk.git", branch: "main"bundle installClient: talk to an agent over stdio
ACP calls must run inside an Async reactor. Sync { ... } starts one, or reuses the current task if you are already inside Falcon/Async.
require "acp_sdk_async"
S = ACP::Schema
class MyClient
def session_update(notification)
update = notification.update
print update.content.text if update.is_a?(S::AgentMessageChunk)
end
def request_permission(request)
option = request.options.find { |candidate| candidate.kind.start_with?("allow") }
return S::RequestPermissionResponse.new(outcome: S::DeniedOutcome.new) if option.nil?
S::RequestPermissionResponse.new(outcome: S::AllowedOutcome.new(option_id: option.option_id))
end
end
Sync do
process = ACP::Stdio.spawn_agent("my-agent", "--acp", cwd: Dir.pwd, stderr: :log)
client = process.connect(MyClient.new)
client.initialize_agent(client_info: S::Implementation.new(name: "my-client", version: "1.0"))
session = client.new_session(cwd: Dir.pwd)
response = client.prompt(
session_id: session.session_id,
prompt: [S::TextContentBlock.new(text: "Hello!")]
)
puts response.stop_reason
client.close
process.close
endACP::Client::Connection exposes every agent method (initialize_agent, authenticate, new_session, load_session, list_sessions, fork_session, resume_session, set_session_mode, set_session_config_option, prompt, cancel, logout, ext_method, ext_notification, ...). Request arguments are keyword arguments matching the schema fields in snake_case; responses come back as typed models.
The client handler receives typed models too: session_update(S::SessionNotification), request_permission(S::RequestPermissionRequest), read_text_file, write_text_file, create_terminal, terminal_output, release_terminal, wait_for_terminal_exit, kill_terminal, create_elicitation, complete_elicitation, ext_method(name, params), ext_notification(name, params). Methods the handler does not implement return method not found to the peer.
Incoming requests run as child tasks so a handler can call back into the peer without deadlocking. Notifications are delivered in order on a single task. After prompt returns, queued session/update handlers have already finished.
Agent: serve the protocol over stdin/stdout
require "acp_sdk_async"
S = ACP::Schema
class EchoAgent
def on_connect(connection)
@connection = connection
end
def initialize_acp(request)
S::InitializeResponse.new(
protocol_version: request.protocol_version,
agent_info: S::Implementation.new(name: "echo-agent", version: "0.1")
)
end
def new_session(_request)
S::NewSessionResponse.new(session_id: SecureRandom.uuid)
end
def prompt(request)
text = request.prompt.map { |block| block.respond_to?(:text) ? block.text : "" }.join
@connection.session_update(
session_id: request.session_id,
update: S::AgentMessageChunk.new(content: S::TextContentBlock.new(text: text.reverse))
)
S::PromptResponse.new(stop_reason: "end_turn")
end
def cancel(_notification); end
end
ACP::Agent.run_agent(EchoAgent.new)run_agent starts the reactor with Sync and listens until stdin closes. The ACP initialize method maps to initialize_acp on the agent handler so it does not collide with Ruby's constructor. Every other method keeps its snake_case name (new_session, load_session, prompt, cancel, set_session_mode, ...).
Schema models
ACP::Schema contains a class for every definition in the ACP schema. Models accept snake_case keyword arguments, serialize to camelCase JSON with to_h/to_json, and parse with from_hash. Unions (ContentBlock, SessionUpdate, ToolCallContent, ...) resolve to the right variant automatically; enums and required fields are validated on construction and on parse.
block = ACP::Schema::ContentBlock.from_hash({ "type" => "text", "text" => "hi" })
block.class # => ACP::Schema::TextContentBlock
block.to_h # => { "type" => "text", "text" => "hi" }ACP::PROTOCOL_VERSION, ACP::AGENT_METHODS, ACP::CLIENT_METHODS and ACP::PROTOCOL_METHODS mirror the official meta.json.
Lower level
ACP::Connection is the transport-agnostic JSON-RPC layer (requests with optional timeouts, ordered notification delivery, observers for tracing traffic). ACP::NdjsonTransport wraps a pair of IO objects; ACP::MemoryTransport.pair gives two in-memory ends for tests. Inbound lines are capped at 50MB by default (max_line_bytes:) so a rogue peer cannot exhaust memory.
Spawning and signal handling (ACP::Stdio.spawn_agent, AgentProcess#kill) target POSIX platforms. The Windows inherited-environment allowlist ships untested — there is no Windows CI yet.
See examples/duet.rb for a runnable in-process agent↔client demo (ruby -Ilib examples/duet.rb "hello").
Logging goes through ACP.logger (a Logger, WARN level by default); assign your own to integrate with the host application.
Updating the schema
schema/schema.json, schema/meta.json and schema/VERSION are vendored from the ACP repository. Currently vendored: schema-v1.21.0. After replacing them run:
bundle exec rake gen_schema
bundle exec rake testlib/acp/schema.rb and lib/acp/meta.rb are generated files; do not edit them by hand.
Development
bundle install
bundle exec rake testLicense
This project is dedicated to the public domain under the Unlicense. You may use, copy, modify, and distribute the code without restriction.