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.
Traces and metrics
A conversation is worth reading afterwards: which turn took the time, what the agent asked
the editor for, which of them failed. @otel wraps either side and writes that
down as spans and as durations — the signals the OpenTelemetry GenAI conventions define,
named from a registry rather than from this library, so a backend already knows what they
mean.
| Span | Written by | Carries |
|---|---|---|
invoke_agent {agent} |
@otel.traced, one per prompt |
the agent and its version, the conversation — the session — and how the turn ended |
execute_tool {method} |
@otel.traced_client, one per file or terminal request |
the tool, named after the method it answers, and the conversation |
gen_ai.invoke_agent.duration |
@otel.metered, one observation per prompt |
a histogram in seconds: the operation, and the agent |
gen_ai.execute_tool.duration |
@otel.metered_client, one observation per answered request |
a histogram in seconds: the tool, and the agent |
A span covers the call it names. A turn that failed ends as an error carrying the failure;
a turn that was cancelled ends as Ok, with cancelled among its
finish reasons. Prompt and answer text is never recorded — a trace is read by more people
than the conversation is.
The two wrappers compose, so a program that wants both writes
@otel.metered(@otel.traced(agent, tracer), metrics) and hands the result to the
transport it already runs.
let tracer = @trace.Tracer::new(
rand=width => Bytes::make(width, b'\x21'),
sink=@trace.Recorder::new(),
sampler=@trace.always,
)
@stdio.serve_stdio(@otel.traced(MyAgent::new(), tracer))
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.