An editor talks,
an agent answers.
Sessions, streamed answers, permission prompts, files and terminals — the conversation between a code editor and a coding agent, in both directions, over the pipe an editor launches an agent with or over a single HTTP endpoint.
moon add moonbitstack/moonacp
Both sides
An Agent to implement as a server, a Client to implement as an editor, and the session handle each of them talks through.
One transport, twice
@stdio for the pipe an editor owns, @asgi for one endpoint carrying HTTP streams and a WebSocket.
Cancellation that lands
A cancelled turn stops at its next await and still answers once, even when the cancel arrives before the turn has started.
Nothing invented
Every message is the published schema's own shape; each type reads and writes its wire form once, and nothing else.
Two transports, one protocol
An editor that started the agent talks to it on the process's standard input and output. An agent behind a network gets an ASGI application instead: one path, POST for what the client says, long-lived GET streams for what the agent answers, DELETE to end a connection, and a WebSocket upgrade that carries the same messages on a single channel.
import {
"moonbitstack/moonacp",
"moonbitstack/moonacp/stdio",
}
async fn main {
let agent : &@moonacp.Agent = MyAgent::new()
@stdio.serve_stdio(agent)
}
For a server, to_asgi hands back an application any ASGI runtime can run, and
the same agent code works: the transport is the only thing that changes.
import {
"moonbitstack/moonacp",
"moonbitstack/moonacp/asgi",
}
let app = @asgi.to_asgi(MyAgent::new())
initialize answer — travel in
_meta and in a response header, and are documented as such.
Guide
An agent is a value with three required methods and a handful of optional ones. The required three open a conversation, start a session, and run a turn; everything else has a default that reports itself as unsupported, so an agent only advertises what it answers.
impl @moonacp.Agent for MyAgent with fn initialize(self, request) {
ignore(self)
ignore(request)
{
..@moonacp.Initialized::new(),
capabilities: {
..@moonacp.AgentCapabilities::none(),
prompt: { image: true, audio: false, embedded_context: true, meta: None },
},
agent_info: Some(@moonacp.Implementation::new("my-agent", "0.1.0")),
}
}
impl @moonacp.Agent for MyAgent with fn prompt(self, request, session) {
let text = request.prompt.map(block => match block {
@moonacp.ContentBlock::Text(content) => content.text
_ => ""
}).join(" ")
session.update(@moonacp.SessionUpdate::AgentMessageChunk({
content: @moonacp.ContentBlock::Text({
text: "You said: " + text,
annotations: None,
meta: None,
}),
message_id: None,
meta: None,
}))
@moonacp.StopReason::EndTurn
}
Inside a turn the Session is the way back to the editor: send an update, ask
the user to allow a tool call, read or write a file, open a terminal, put a form in front
of them. Each of those is a request the client answers, and a client that never advertised
the capability answers -32601 — so check before asking.
let outcome = session.request_permission({
tool_call: { tool_call_id: "c-1", title: "write /work/notes.md", … },
options: [
{ option_id: "allow", name: "Allow", kind: @moonacp.PermissionOptionKind::AllowOnce, meta: None },
{ option_id: "no", name: "No", kind: @moonacp.PermissionOptionKind::RejectOnce, meta: None },
],
meta: None,
})
match outcome {
@moonacp.Outcome::Selected(option) => println("chose " + option)
@moonacp.Outcome::Cancelled => println("dismissed")
}
The other side is a client: it answers what the agent asks and drives the session. Start the agent as a child process, or connect to one over any transport you write yourself.
let child = @stdio.spawn(my_client, ["/usr/local/bin/my-agent"])
let conn = child.conn()
@async.with_task_group(async fn(group) {
group.spawn_bg(async fn() { conn.run() })
let started = conn.initialize({
protocol_version: @moonacp.protocol_version,
client_capabilities: @moonacp.ClientCapabilities::none(),
client_info: None,
meta: None,
})
println("talking to " + started.agent_info.map(info => info.name).unwrap_or("an agent"))
})
Consistency
The official conformance suite runs against the example agent on every push, as do both official SDKs — theirs calling ours, and ours calling theirs. Every message either side writes is checked against the published schema as it is written.
| Suite | Pinned at | Result |
|---|---|---|
acp-tck, the official conformance suite |
commit b7f40fb, v1 suite |
conformant: every mandatory requirement and every declared capability passes |
The published schema.json |
v1.29.1 | every message in a scripted session, both directions |
| Official TypeScript client → this library's agent | @agentclientprotocol/sdk 2.4.0 |
conversation, streamed answer, cancel |
| Official Python client → this library's agent | agent-client-protocol 0.12.1 |
conversation, streamed answer, cancel |
| This library's client → official TypeScript agent | @agentclientprotocol/sdk 2.4.0 |
conversation, streamed answer |
| This library's client → official Python agent | agent-client-protocol 0.12.1 |
conversation, streamed answer |
MCP server handed to session/new |
acp-tck's stdio stub |
spawned, hand-shaken, tools asked for |
_meta
as well as in the Acp-Connection-Id header.