Agentcomms is a Common Lisp implementation of the Agent Client Protocol (ACP), the open protocol that editors such as Zed, Neovim, Emacs, and JetBrains use to drive coding agents over newline-delimited JSON-RPC on standard I/O. The library speaks ACP protocol version 1, the stable revision; the v2 draft is not implemented yet. It leaves product decisions to the program that embeds it: an agent subclasses the agent peer and specializes a handful of generic functions, and a client does the same on its side.
The library handles:
- JSON-RPC 2.0 framing, request correlation, and bidirectional requests
- threaded request handling so cancellation reaches a running prompt
$/cancel_request,session/cancel, and the cancelled stop reason- protocol version negotiation and capability gating on both sides
- every ACP v1 method, notification, and object constructor
- standard I/O channels for agents and a subprocess launcher for clients
- WebSocket channels for remote agents, with an HTTP/1.1 request reader
- a remote agent server for Streamable HTTP and WebSocket clients
- an in-process channel pair for tests and embedded peers
- bounded message sizes and bounded JSON documents
- ASDF system:
agentcomms - Test system:
agentcomms/tests - Package:
AGENTCOMMS, nicknameACP
Install the locked dependencies and run all tests:
./script/bootstrap
./script/checkWire values follow argo’s value model: objects are EQUAL hash tables with
string keys, arrays are vectors, true is T, false is argo’s (JSON-FALSE)
marker, and null is NIL. JSON-OBJECT builds an object from alternating
keys and values and omits NIL values, so optional fields can be passed
straight through; write :NULL for an explicit null. JSON-GET reads a
field with a default and reports false and null as NIL; GETHASH tells
them apart. JSON-SEQUENCE->LIST turns an array into a list, and
ACP-FIELD validates one field of a received object, signalling an Invalid
Params error for the peer when the type is wrong.
Enumerations map between keywords and wire strings:
(STOP-REASON-STRING :END-TURN) is "end_turn" and
(TOOL-KIND-KEYWORD "read") is :READ. The -KEYWORD functions take a
:DEFAULT for values newer peers may send.
Constructors named ACP- build every protocol object: content blocks
(ACP-TEXT-CONTENT, ACP-IMAGE-CONTENT, ACP-RESOURCE-LINK,
ACP-EMBEDDED-RESOURCE), tool calls (ACP-TOOL-CALL, ACP-TOOL-CALL-UPDATE,
ACP-DIFF-CONTENT, ACP-TERMINAL-CONTENT), plans, permission options, modes,
configuration options, MCP server configurations, capabilities, and the
session updates (ACP-UPDATE-AGENT-MESSAGE, ACP-UPDATE-TOOL-CALL,
ACP-UPDATE-PLAN, and the rest).
Subclass ACP-AGENT, specialize the generic functions you support, and
serve standard I/O:
(defclass my-agent (acp:acp-agent) ())
(defmethod acp:agent-implementation ((agent my-agent))
(acp:acp-implementation "my-agent" "1.0.0" :title "My Agent"))
(defmethod acp:agent-capabilities ((agent my-agent))
(acp:acp-agent-capabilities :image t :mcp-http t))
(defmethod acp:agent-new-session ((agent my-agent) &key cwd mcp-servers additional-directories params)
(declare (ignore mcp-servers additional-directories params))
(values (start-conversation cwd)
(acp:json-object "modes" (acp:acp-session-mode-state
"code" (list (acp:acp-session-mode "code" "Code"))))))
(defmethod acp:agent-prompt ((agent my-agent) session-id prompt &key params)
(declare (ignore params))
(dolist (block prompt)
(acp:agent-check-cancelled agent session-id)
(acp:agent-send-update agent session-id
(acp:acp-update-agent-message
(acp:acp-text-content (answer (acp:acp-content-text block))))))
:end-turn)
(acp:acp-serve-standard-io (make-instance 'my-agent))The baseline methods are AGENT-NEW-SESSION, AGENT-PROMPT, and
AGENT-CANCEL. The library records sessions, validates parameters, answers
initialize with the negotiated version, and only dispatches
session/load, session/resume, session/close, session/list,
session/delete, and logout when AGENT-CAPABILITIES advertises them.
A prompt turn ends when AGENT-PROMPT returns a stop reason keyword. When
the client sends session/cancel, the library marks the session, calls
AGENT-CANCEL so the implementation can interrupt provider requests, and
turns the turn’s result into the :CANCELLED stop reason, whether the
implementation returned normally, signalled ACP-PROMPT-CANCELLED from
AGENT-CHECK-CANCELLED, or failed with any other error.
While a turn runs, the agent calls the client through
AGENT-REQUEST-PERMISSION, AGENT-READ-TEXT-FILE, AGENT-WRITE-TEXT-FILE,
AGENT-CREATE-TERMINAL and the other terminal functions, and
AGENT-CREATE-ELICITATION. Each one signals ACP-CAPABILITY-ERROR when the
client never advertised the capability, so implementations can fall back
before sending anything. AGENT-SEND-UPDATE streams session updates.
Create one update buffer per prompt to combine small text thoughts:
(let ((updates (acp:make-agent-update-buffer agent session-id :thought-batch-size 800)))
(acp:update-buffer-send updates
(acp:acp-update-agent-thought (acp:acp-text-content "Thinking...")))
(acp:update-buffer-flush updates))Send every update for that prompt through UPDATE-BUFFER-SEND to preserve
ordering. Flush before requesting permission and before returning a prompt
result, including cancellation and error exits. Use a synchronous notification
sender; request client decisions after flushing, outside the buffer’s lock.
Compatible text thoughts retain their metadata and coalesce up to the character
threshold. Large fragments and other content pass through after pending text.
If delivery fails, retry UPDATE-BUFFER-FLUSH to publish the retained batch.
MAKE-AGENT-UPDATE-BUFFER validates complete notifications against the connected
channel’s message limit before joining fragments. For another transport, use
MAKE-ACP-UPDATE-BUFFER with a sender and a :validator function that checks its
complete wire message.
Extension methods, whose names begin with an underscore, reach
AGENT-EXTENSION-REQUEST and AGENT-EXTENSION-NOTIFICATION.
Subclass ACP-CLIENT, launch the agent, and drive it:
(defclass my-client (acp:acp-client) ())
(defmethod acp:client-capabilities ((client my-client))
(acp:acp-client-capabilities :read-text-file t :write-text-file t))
(defmethod acp:client-session-update ((client my-client) session-id update &key params)
(declare (ignore session-id params))
(when (eq (acp:acp-update-kind update) :agent-message-chunk)
(write-string (acp:acp-content-text (acp:json-get update "content")))))
(defmethod acp:client-request-permission ((client my-client) session-id tool-call &key options params)
(declare (ignore session-id tool-call params))
(values :selected (acp:json-get (first options) "optionId")))
(defmethod acp:client-read-text-file ((client my-client) session-id path &key line limit params)
(declare (ignore session-id line limit params))
(uiop:read-file-string path))
(let* ((channel (acp:acp-launch-agent "/usr/local/bin/some-agent" :arguments '("--acp")))
(client (make-instance 'my-client)))
(acp:acp-client-connect client channel)
(unwind-protect
(progn
(acp:client-initialize client)
(let ((session-id (acp:client-new-session client "/home/me/project")))
(acp:client-prompt client session-id
(list (acp:acp-text-content "Summarize this project.")))))
(acp:connection-close (acp:acp-client-connection client))))CLIENT-INITIALIZE sends this client’s capabilities and implementation
info, records the agent’s capabilities and authentication methods, and
signals ACP-UNSUPPORTED-VERSION when the agent selects a version the
library cannot speak. CLIENT-NEW-SESSION, CLIENT-LOAD-SESSION,
CLIENT-PROMPT accepts :ON-SENT to receive the written wire request id before
waiting for the prompt result. CLIENT-CANCEL, CLIENT-SET-CONFIG-OPTION,
CLIENT-LIST-SESSIONS, and the rest mirror the agent methods; the optional
ones signal ACP-CAPABILITY-ERROR when the agent never advertised them.
Agent requests arrive through CLIENT-REQUEST-PERMISSION,
CLIENT-READ-TEXT-FILE, CLIENT-WRITE-TEXT-FILE, the CLIENT- terminal
functions, and CLIENT-CREATE-ELICITATION. File system, terminal, and
elicitation requests are only dispatched when CLIENT-CAPABILITIES
advertised them; otherwise the agent receives Method Not Found.
Closing the connection closes the agent’s input, waits for it to exit, and
terminates it when it does not. ACP-PROCESS-CHANNEL-STDERR-TEXT holds a
bounded tail of the agent’s standard error and
ACP-PROCESS-CHANNEL-EXIT-CODE its exit code.
ACP-CONNECTION is a symmetric JSON-RPC connection over an ACP-CHANNEL.
Incoming requests run on their own threads so a long request never blocks
the notification that cancels it; incoming notifications run on the reader
thread in arrival order. CONNECTION-REQUEST returns the peer’s result or
signals ACP-REMOTE-ERROR, ACP-TIMEOUT after sending $/cancel_request,
or ACP-CONNECTION-CLOSED. Its optional :ON-SENT callback receives the integer
wire request id after the request is written and before waiting for the result, which
allows a caller to send a matching cancellation request without guessing the id. Request handlers signal ACP-METHOD-ERROR to
answer with a specific JSON-RPC error and call ACP-CHECK-CANCELLED to
honor cancellation.
Channels carry message lines. ACP-STANDARD-IO-CHANNEL wraps this
process’s standard streams as UTF-8, ACP-LAUNCH-AGENT wraps a subprocess,
MAKE-ACP-STREAM-CHANNEL wraps any pair of character streams, and
MAKE-ACP-CHANNEL-PAIR returns two connected in-memory channels, and
MAKE-ACP-WEBSOCKET-CHANNEL and ACP-WEBSOCKET-CONNECT carry messages over
a WebSocket. Every channel bounds its messages at
*ACP-MAXIMUM-MESSAGE-CHARACTERS*, 16 MiB by default; longer incoming
messages are discarded and answered with a Parse Error.
HTTP1-READ-REQUEST reads one request from a binary stream and returns an
HTTP-REQUEST, or NIL when the input ends before the request starts. It
accepts HTTP/1.0 and HTTP/1.1, CRLF or bare LF line ends, and bodies framed
by Content-Length or chunked Transfer-Encoding. The head is limited to
*HTTP-MAXIMUM-HEADER-OCTETS* and the body to *HTTP-MAXIMUM-BODY-OCTETS*;
both limits are also keyword arguments. When the request has
Expect: 100-continue and the stream is also an output stream, the reader
sends the interim response before it reads the body.
A request it cannot accept signals HTTP-REQUEST-ERROR.
HTTP-REQUEST-ERROR-STATUS is the status to answer with: 400 for a
malformed request, 413 for a large body, 431 for a large head, 501 for an
unknown transfer coding, and 505 for another protocol version.
HTTP-REQUEST-ERROR-HEADERS lists header fields that belong in that answer.
The HTTP/2 connection preface starts like a request. When the request line
is PRI * HTTP/2.0, the reader returns that request after the empty line
that follows it and reads nothing more, so the caller can read the rest of
the preface and switch protocols.
MAKE-HTTP-REQUEST builds the same object from parts, for requests that
arrive another way. The readers are HTTP-REQUEST-METHOD,
HTTP-REQUEST-TARGET, HTTP-REQUEST-PATH (the target without its query),
HTTP-REQUEST-QUERY (decoded name and value pairs), HTTP-REQUEST-VERSION,
HTTP-REQUEST-HEADERS (lowercase names in arrival order), and
HTTP-REQUEST-BODY (octets). HTTP-REQUEST-HEADER returns one field and
joins repeated fields with a comma, HTTP-REQUEST-HEADER-TOKEN-P tests a
comma-separated field such as Connection for a token, and
HTTP-REQUEST-KEEP-ALIVE-P tells whether the connection stays open after
the response.
HTTP1-WRITE-RESPONSE writes a status line, header fields, a computed
Content-Length, and a body given as octets or a UTF-8 string.
HTTP1-WRITE-RESPONSE-HEAD writes only the status line and header fields,
for 101 responses and for bodies that end when the connection closes, such
as Server-Sent Events.
(let ((request (acp:http1-read-request stream)))
(when request
(acp:http1-write-response stream 200
:headers '(("Content-Type" . "text/plain"))
:body "ready")))The WebSocket layer follows RFC 6455 without extensions or TLS. On a
server, WEBSOCKET-UPGRADE-REQUEST-P recognizes an upgrade request and
WEBSOCKET-ACCEPT checks it, writes the 101 response with any extra header
fields, and returns a WEBSOCKET-CONNECTION. An invalid handshake signals
HTTP-REQUEST-ERROR before anything is written, with status 426 and a
Sec-WebSocket-Version header for an unsupported version and 400
otherwise. Pass the socket as :SOCKET so that closing the connection can
wake a thread blocked reading it.
(let* ((socket (ls-compat.tcp:tcp-accept listener))
(stream (ls-compat.tcp:tcp-stream socket))
(request (acp:http1-read-request stream)))
(if (acp:websocket-upgrade-request-p request)
(acp:acp-agent-serve agent
(acp:make-acp-websocket-channel
(acp:websocket-accept stream request
:headers '(("Acp-Connection-Id" . "c-1"))
:socket socket)))
(acp:http1-write-response stream 404)))On a client, WEBSOCKET-CONNECT opens a ws:// URL with extra request
headers such as Authorization, and ACP-WEBSOCKET-CONNECT returns an ACP
channel for ACP-CLIENT-CONNECT. A wss:// URL signals WEBSOCKET-ERROR;
put a TLS proxy in front instead. A refused handshake signals
WEBSOCKET-HANDSHAKE-ERROR with the server’s status in
WEBSOCKET-HANDSHAKE-ERROR-STATUS. WEBSOCKET-HANDSHAKE-HEADERS holds the
101 response’s header fields.
WEBSOCKET-READ-MESSAGE returns the next text message as a string or binary
message as octets, with :TEXT or :BINARY as a second value, and NIL
after the close handshake or the end of the stream. It answers pings,
reassembles fragments, and fails the connection on a protocol error (status
1002), invalid UTF-8 text (1007), or a message longer than
*WEBSOCKET-MAXIMUM-MESSAGE-OCTETS* (1009); the failure signals
WEBSOCKET-ERROR with that status in WEBSOCKET-ERROR-CODE.
WEBSOCKET-SEND-TEXT, WEBSOCKET-SEND-BINARY, and WEBSOCKET-SEND-PING
send one frame each and may run on several threads while one thread reads.
WEBSOCKET-CLOSE sends a close frame. While another thread reads, it waits
up to *WEBSOCKET-CLOSE-SECONDS* for the peer’s close frame, then shuts the
socket down. WEBSOCKET-CLOSE-CODE and WEBSOCKET-CLOSE-REASON report how
the connection ended.
The ACP channel sends each message as one text frame and ignores binary messages. Its read limit is four octets per allowed character: a longer message fails the WebSocket with status 1009, and a shorter one with too many characters is discarded and answered with a Parse Error, as on the other channels.
ACP-REMOTE-SERVER-START serves an agent to remote clients under the ACP
proposal “Streamable HTTP & WebSocket Transport”. One endpoint, /acp,
takes three kinds of client:
- WebSocket: a GET with
Upgrade: websocket, then one JSON-RPC message per text frame. - Streamable HTTP: the client POSTs each message.
initializecreates a connection and answers 200 with the agent’s response and anAcp-Connection-Idheader; every later request carries that header and gets 202. The agent’s messages arrive as Server-Sent Events on GET streams: one for the connection, and one per session opened with anAcp-Session-Idheader. Responses tosession/newandsession/loadtravel on the connection stream. DELETE ends the connection. - HTTP/2, through llh2, with prior knowledge or an
Upgrade: h2c. HTTP/1.1 clients are served as well, since many cleartext HTTP clients cannot speak HTTP/2; their event streams end with the connection.
(let ((server (acp:acp-remote-server-start
(lambda (channel) (acp:acp-agent-serve (make-instance 'my-agent) channel))
:host "127.0.0.1"
:port 8737
:token "s3cret")))
(format t "Serving ACP at ~A~%" (acp:acp-remote-server-url server))
...
(acp:acp-remote-server-stop server))The function receives one channel per client connection, in its own thread,
and returns when that connection ends. :TRANSPORTS is (:HTTP :WEBSOCKET)
by default; (:WEBSOCKET) serves WebSocket alone. A port of 0 picks a free
one, which ACP-REMOTE-SERVER-PORT reports.
With :TOKEN, every request needs Authorization: Bearer TOKEN or the token
in a token query parameter, or it gets 401. Requests with an Origin
header get 403 unless :ALLOWED-ORIGINS lists it: browsers send it, so a web
page cannot drive a local agent. The server speaks cleartext only; a
reverse proxy adds TLS. Streamable HTTP connections without an open event
stream or a request for *ACP-REMOTE-IDLE-SECONDS* end, and their sessions
stay with the agent for a later session/load.
- argo
- babel
- Bordeaux Threads
- cl-base64
- Ironclad
- llh2
- ls-compat (
ls-compat/tcp) - Serapeum
- usocket
- ASDF/UIOP
The protocol types follow the upstream schema at
*ACP-SCHEMA-REFERENCE*.
./script/bootstrap
./script/check
./script/check --fix./script/check runs the tests, then checks the source with
cheque as cheque.sexp
configures it. --fix skips the tests and repairs what cheque can repair.
cheque and its tools are development dependencies: checks/qlfile lists them,
and the check installs them on first use into ~/.cache/cheque/tools/.
COLL-Attribution, copyright 2026 Lambda Symbolics OÜ. See LICENSE.lisp.