The Agent Client Protocol (ACP) lets an editor, such as Zed, run a coding agent as a child process and talk to it in JSON-RPC over stdin and stdout. The editor opens a session for a folder, sends prompts, shows the agent’s messages and tool calls as they happen, and asks the person to allow or refuse each action the agent wants to take. This module is the ramabana-acp command: it puts a Ramabana agent behind that protocol.
What maps onto what
Each part of the protocol already has a counterpart in Ramabana, so this module translates between them and adds no behaviour of its own.
session/prompt, the editor sending a prompt, is one turn.
The session/update messages that stream the reply carry the chunks of Agent.stream.
The tool calls the editor shows are Ramabana’s Activity rows, one per tool call.
session/request_permission, the editor’s allow-or-refuse prompt, is the approval gate, Approvals. The gate waits for the editor’s answer. If the editor fails to answer, the action is refused, so losing the editor never lets a write through.
session/load, reopening an earlier conversation, is resume_session, so a conversation started in the terminal can continue in the editor.
Two requests go the other way, from the agent to the editor. A client that offers fs/read_text_file can hand over the text of a file as it stands in an open buffer, including changes not yet saved to disk. A client that offers terminal/* can run a command in a terminal the person can watch. When the editor offers these, Ramabana reads and runs through it, so the agent works on what the person sees.
The bridge
Ramabana runs every tool and every host method on the turn’s worker thread, a plain thread that blocks while it waits. The ACP library runs on an asyncio event loop. A tool that needs the editor, for example to read a buffer, has to hand a coroutine to that loop and wait for the result. Bridge is the one place that does this. call sends a request and blocks until the editor replies or timeout passes, and post sends an update without waiting.
def Bridge( conn, # the connected ACP `Client` sid, # the session every call is about loop, # the loop serving the connection timeout:int=300, # seconds to wait on a person or editor):
The one crossing between the turn’s thread and the client’s event loop.
The host the editor answers for
EditorHost is the ordinary WorkspaceHost from ramabana.vault, with four methods sent to the editor when the editor says it can serve them: read, text_at, write and run_cmd. attach records what the editor offered.
The four differ in what happens when the editor fails. A read falls back to the file on disk, because a read the editor declined should not cost the turn. A write does not fall back: if the editor refused the write, that refusal is the answer, and writing to disk anyway would defeat the reason for asking the editor. A command does not fall back once the editor’s terminal has opened, because by then the command has run and a second run on this side would run it twice.
read matters most. view_file reads through Host.read, so the text the model sees, and against which it writes its edits, is the same text those edits are applied to. If the two came from different places, the edits would be addressed to text that is not there.
An ACP prompt is a list of content blocks. blocks turns that list into the text and media that Agent takes. Text blocks join into one string, separated by blank lines. An embedded file becomes a <file> element holding its text, and a link to a resource becomes @uri. Image and audio blocks are decoded to bytes and passed to the model when its capabilities accept that kind of media, or when its capabilities are unknown. Otherwise the block is dropped and a line saying so is added to the text. The examples under Tests show a text prompt and an image.
ACP content blocks as (text, media), keeping the media spec accepts.
The session
Session holds one editor session: one agent, its host, and the bridge to the editor. Everything the editor shows comes from hooks the agent already has. on_activity reports each tool call, on_plan reports the agent’s checklist, and Approvals.listen is told about each approval request. Session turns each of these into the matching ACP update. For an edit it also works out the file’s text before and after, without writing anything, so the editor can show a diff in its permission prompt.
One ACP session: an agent, and the editor it reports to.
The editor’s permission prompt offers three choices. The second reads “Allow every write this session”, so choosing it answers the request with scope='edits': file and notebook edits stop asking for the rest of the session, while shell commands and add_root still ask. Only the terminal’s own approve-all key switches the gate to auto. The example below stands in an editor that always picks that option, and checks what the gate decides afterwards.
AcpAgent is the object the ACP library drives. It answers initialize, opens a Session for each session/new, resumes one for session/load and replays its turns to the editor, runs session/prompt, and handles cancel and close. A prompt that starts with / runs as a Ramabana slash command instead of a turn.
initialize records the capabilities the editor declares, and EditorHost.attach turns on only those. A client that declares none gets a host that reads, writes and runs on this machine, as a plain WorkspaceHost would.
serve speaks the protocol on stdin and stdout until the editor closes the connection, and main is the ramabana-acp command around it. Its options are listed on the index page.
def main( root:str='.', # comma-separated folders if the editor names none model:str=None, # turn model; omit for the routing default web:bool=True, # let web tools reach the network via fossick vault:bool=False, # keep reads in a vishalakshi vault pii:str='off', # off | redact | refuse PII from the vault pii_ner:bool=False, # --pii also gates titled names, not only patterns cfg:str=None, # config dir, for skills, extensions and history):
Serve Ramabana over the Agent Client Protocol for an editor.
tests/test_acp.py tests the wire protocol: it starts ramabana-acp as a subprocess and speaks JSON-RPC to it. The cells below check the translation and the host. The first two run blocks on a text prompt and on a prompt with an image.
png =b'\x89PNG\r\n\x1a\n'text, media = blocks([acp.text_block('what is this'), acp.image_block(base64.b64encode(png).decode(), 'image/png')])test_eq(media, [png])test_eq(text, 'what is this')
KIND, TOOL and PLAN must use only values the protocol defines, which the cell above checks against the ACP schema. An EditorHost with no editor attached behaves as a plain WorkspaceHost. The empty command that tools_for sends to test whether a host can run commands must never reach the editor:
h = EditorHost(['.'])test_eq((h.can_read, h.can_write, h.can_run), (False, False, False))test_eq(h.run_cmd(''), (0, ''))