Protocol library · MoonBit

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())
The remote transport is a request for discussion, not a finished specification. Its revision of 2026-11-04 is what this library implements; the parts the schema has no room for — the connection identifier in the 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.

SuitePinned atResult
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
Speaking MCP is the agent's job and not the protocol's, so the library carries the servers' records and the example does the connecting: it spawns each stdio server a client names, completes the handshake, and asks what tools it offers. One thing the schema does not model: the remote transport's connection identifier, which travels in _meta as well as in the Acp-Connection-Id header.
Apache-2.0 · MoonBit · schema v1.29.1 GitHub