Agent Client Protocol (ACP) Internal Architecture in Goose
The Agent Client Protocol (ACP) in block/goose is implemented as a multi-layered async server that wraps the core Goose agent API, exposing it via JSON-RPC over HTTP, WebSocket, or stdio through per-session agents and bidirectional channel adapters.
The ACP implementation resides in the crates/goose-acp directory of the block/goose repository. It acts as a translation layer that accepts Agent Client Protocol messages from external clients and maps them to the native Goose agent interface, maintaining full compatibility with the existing permission system, extension registry, and tool execution framework.
Core Architecture Layers
The ACP stack is organized into tightly-coupled layers, each handling a specific responsibility in the communication pipeline.
Server Factory and Configuration
The entry point for ACP sessions is the server factory, defined in crates/goose-acp/src/server_factory.rs. This module reads the user's goose.yaml configuration, initializes the selected Provider (model backend), and constructs an AcpServer instance capable of spawning new agents.
When the binary runs goose acp, it creates an AcpServerFactoryConfig containing builtins, data directories, and configuration paths. The factory then instantiates the server:
let factory = AcpServerFactoryConfig { … };
let server = AcpServer::new(factory);
GooseAcpAgent and Session State
Each incoming connection receives a dedicated GooseAcpAgent instance managed in crates/goose-acp/src/server.rs. This struct holds per-session state including the conversation history, pending tool requests, and cancellation tokens.
The agent implements the full Goose interface, handling initialization, extension loading, permission flows, and tool execution. Unlike the standard Goose server which may share state, the ACP version creates isolated sessions via AcpServer::create_agent to ensure client separation.
Transport Implementations
The transport layer exposes two concrete implementations in crates/goose-acp/src/transport/http.rs and crates/goose-acp/src/transport/websocket.rs. Both use Axum routers to handle incoming connections.
Each transport manages a TransportSession stored in a RwLock<HashMap<String, TransportSession>> within HttpState or WsState. When a client sends a JSON-RPC request to the /acp endpoint, the server routes it through handle_initialize, handle_request, or handle_notification_or_response depending on the message type.
Async I/O Adapters
To bridge the gap between Axum's channel-based communication and the core agent's expectations, crates/goose-acp/src/adapters.rs provides ReceiverToAsyncRead and SenderToAsyncWrite wrappers. These adapters convert mpsc::Receiver<String> and mpsc::Sender<String> into Tokio AsyncRead and AsyncWrite traits.
This abstraction allows the generic ACP server code to remain transport-agnostic while still supporting HTTP Server-Sent Events (SSE) and WebSocket frames.
Tool Metadata and Filesystem Extensions
The crates/goose-acp/src/tools.rs module adds ACP-specific metadata to tool results through the with_acp_aware_meta() method on CallToolResult. Clients can query is_acp_aware() to identify tool calls that originated from an ACP session, enabling specialized UI rendering.
Additionally, crates/goose-acp/src/fs.rs exposes a virtual filesystem extension that allows ACP clients to read and write workspace files directly through the protocol.
Request Lifecycle
Understanding how these layers interact requires examining the message flow from connection initialization to tool execution.
-
Transport Selection: The server starts either HTTP or WebSocket listeners using
transport::http::create_routerortransport::websocket::create_router, both sharing the same state management logic. -
Session Initialization: When a client sends the initial JSON-RPC
initializerequest,HttpState::create_sessiontriggers:- Creation of bidirectional mpsc channels (
to_agent_tx,from_agent_rx) - Instantiation of a fresh
GooseAcpAgentviaAcpServer::create_agent - Wrapping channels with
ReceiverToAsyncReadandSenderToAsyncWriteadapters - Launching the core agent loop in a background task via
crate::server::serve
- Creation of bidirectional mpsc channels (
-
Session Tracking: The server generates a UUID for the session, returning it in the
Acp-Session-Idheader and storing theTransportSessionin the shared state map. -
Message Processing: Subsequent POST requests to
/acpexecute the following:- Verify session existence via
has_session - Serialize incoming JSON-RPC messages to
to_agent_tx - Stream agent responses from
from_agent_rxto the client as Server-Sent Events (text/event-stream)
- Verify session existence via
-
Tool Execution: When the agent invokes tools, results are optionally enriched with
with_acp_aware_meta()fromtools.rs, marking them for ACP-specific handling. -
Permission Handling: The
GooseAcpAgentreuses the standard Goose permission manager and extension registry, ensuring ACP clients have access to developer tools, MCP extensions, and custom providers without code duplication.
Client Integration Example
The goose-sdk crate provides a reference implementation showing how to connect to an ACP server via stdio. This pattern works for HTTP and WebSocket transports with minor modifications.
The following example spawns goose acp, connects via sacp::ByteStreams, and drives a conversation:
// crates/goose-sdk/examples/acp_client.rs
let transport = sacp::ByteStreams::new(
child_stdin.compat_write(),
child_stdout.compat()
);
Client::builder()
.name("acp-client-example")
.on_receive_notification(|notification, _| {
if let SessionUpdate::AgentMessageChunk(chunk) = ¬ification.update {
if let ContentBlock::Text(text) = &chunk.content {
print!("{}", text.text);
}
}
Ok(())
}, sacp::on_receive_notification!())
.on_receive_request(|req, responder, _| {
let opt = req.options.first().map(|o| o.option_id.clone());
responder.respond(match opt {
Some(id) => RequestPermissionResponse::new(
RequestPermissionOutcome::Selected(
SelectedPermissionOutcome::new(id))),
None => RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled),
})
}, sacp::on_receive_request!())
.connect_with(transport, |cx| async move {
// Initialize the session
let _ = cx.send_request(
InitializeRequest::new(ProtocolVersion::LATEST)
).await?;
// Optional: fetch available extensions
let _ = cx.send_request(GetExtensionsRequest {}).await?;
// Execute a prompt
cx.build_session_cwd()?.run_until(|mut sess| async {
sess.send_prompt(&prompt)?;
let _ = sess.read_to_string().await?;
Ok(())
}).await
})
.await?;
This implementation demonstrates auto-approving permissions, handling streaming agent messages, and managing session state through the ACP protocol.
Summary
- The Agent Client Protocol implementation in
block/gooseis a thin async wrapper around the core agent API, located incrates/goose-acp. - Architecture consists of a server factory (
server_factory.rs), per-session agents (server.rs), dual transport layers (transport/http.rs,transport/websocket.rs), and channel adapters (adapters.rs). - Session management uses UUID-tracked
TransportSessionobjects stored in Axum state, with bidirectional mpsc channels converted toAsyncRead/AsyncWritefor the agent loop. - Communication flows over HTTP (SSE) or WebSocket as JSON-RPC, supporting initialization, tool calls, and permission requests.
- Tool results can be flagged as ACP-aware via
with_acp_aware_meta()to enable client-specific UI behaviors. - Clients can connect via stdio, HTTP, or WebSocket and receive the full Goose feature set including extensions and permission management.
Frequently Asked Questions
What transports does the Goose ACP support?
The Goose ACP supports three transport mechanisms: HTTP with Server-Sent Events (SSE) via the /acp endpoint, WebSocket through the /acp upgrade path, and stdio for local process communication. All transports share the same session management and agent logic, differing only in how they wrap the bidirectional byte streams.
How does the ACP handle concurrent sessions?
Each incoming connection triggers the creation of a unique GooseAcpAgent instance with isolated state. The HttpState maintains a RwLock<HashMap<String, TransportSession>> that maps UUIDs to active sessions. When a client sends the initialize request, the server generates a new UUID, stores the session channels, and returns the ID in the Acp-Session-Id header for subsequent request routing.
Can ACP clients access the same extensions as the standard Goose CLI?
Yes. The GooseAcpAgent reuses the identical extension registry, permission manager, and tool execution framework as the standard Goose server. According to the implementation in crates/goose-acp/src/server.rs, the agent delegates to the core Goose APIs, meaning ACP clients can invoke developer tools, MCP extensions, and custom providers without modification to the underlying logic.
What is the purpose of the ACP-aware metadata flag on tool results?
The ACP-aware metadata flag, added via with_acp_aware_meta() in crates/goose-acp/src/tools.rs, allows clients to distinguish between tool calls originating from an ACP session versus standard tool execution. Clients can check this flag using is_acp_aware() to determine whether to render results in an ACP-specific UI element or handle them differently from standard output.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →