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.

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.

SpanWritten byCarries
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.

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