Inicio

El primer initialize

Una conversación de dos líneas

Todo lo que este libro construye cabe, al principio, en dos líneas de texto. Una la escribes tú y la otra te la contesta un programa. Antes de instalar nada, de abrir un editor o de escribir una sola función, conviene ver esas dos líneas con los ojos, porque el resto del protocolo son variaciones de ese intercambio.

Abre una terminal. Necesitas un Agente que hable el protocolo; cualquiera del registro sirve, y goose es cómodo porque trae el modo integrado en un subcomando que lo deja escuchando en stdin:

1goose acp 2

El cursor se queda parpadeando. El proceso arrancó y está esperando. Todavía no ha dicho nada, porque en este protocolo el Agente nunca habla primero: espera a que el Cliente abra la conversación. Y en este momento el Cliente eres tú.

Pega esto y da Enter:

1{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false}}} 2

La respuesta llega en una sola línea, larga. Con un poco de aire se ve así:

1{ 2 "jsonrpc": "2.0", 3 "id": 0, 4 "result": { 5 "protocolVersion": 1, 6 "agentCapabilities": { 7 "loadSession": true, 8 "promptCapabilities": { "image": true, "audio": false, "embeddedContext": true }, 9 "mcpCapabilities": { "http": true, "sse": false } 10 }, 11 "agentInfo": { "name": "goose", "title": "goose", "version": "…" }, 12 "authMethods": [] 13 } 14} 15

Los valores exactos dependen del Agente y de su versión; la forma es siempre la misma. Acabas de hablar el Agent Client Protocol sin una sola línea de código. Lo que sigue en este capítulo es entender cada pedazo de esas dos líneas, porque cada uno tiene una razón de existir y cada uno se puede romper.

El tubo

Fíjate en cómo viajó el mensaje: por la entrada estándar del proceso, y la respuesta salió por su salida estándar. Sin puerto, sin dirección, sin socket. El Cliente arranca al Agente como subproceso y le habla por stdin; el Agente contesta por stdout. Cuando el Cliente cierra el tubo, el Agente muere. En su forma básica el protocolo no pide más infraestructura que ésa.

¿Y cómo sabe el que lee dónde termina un mensaje y empieza el siguiente? En HTTP lo resuelve la cabecera Content-Length, que anuncia cuántos bytes vienen. Aquí no hay cabeceras: cada mensaje es un JSON en una sola línea, terminado en salto de línea, igual que un archivo de registro donde cada renglón es un evento. Para leerlo basta partir por \n. Para escribirlo hay que asegurarse de que el JSON no traiga saltos de línea adentro, que es lo que JSON.stringify hace por defecto. Si alguna vez mandas un mensaje "bonito", con indentación, el Agente va a recibir media línea, intentar parsearla, y contestar un error de análisis con id: null, porque no pudo ni leer el identificador.

Queda un canal más, y es el que se olvida: stderr. Es del Agente para sus propios registros. Un Cliente bien portado lo captura y lo enseña en un panel de diagnóstico, y nunca lo mezcla con stdout, que es donde viven los mensajes. Un Agente bien portado no imprime jamás en stdout nada que no sea un mensaje del protocolo: un console.log de depuración en el lugar equivocado y el Cliente recibe una línea que no es JSON y se le cae la conexión. Cuando escribas a Ghosty en el capítulo 7, esa va a ser la primera regla: stdout es sagrado.

El sobre

El mensaje que escribiste sigue el formato JSON-RPC 2.0. Son cuatro campos y conviene distinguirlos porque de ellos depende cómo se emparejan preguntas con respuestas cuando hay muchas en vuelo.

jsonrpc siempre trae "2.0". Indica la versión del formato JSON-RPC, que es el envoltorio del mensaje; la versión del protocolo ACP viaja aparte, en params. Este campo nunca cambia.

method es el nombre de lo que pides. En ACP los nombres tienen forma de ruta: initialize, session/new, fs/read_text_file. La barra separa el área del verbo, y esa convención es la que te va a permitir, en el capítulo 10, reconocer de un vistazo qué es estándar y qué es un invento del Agente con el que hablas.

params es el objeto con los argumentos. Cada método define los suyos.

id es el que importa. Es un número o una cadena que tú eliges, y la respuesta trae el mismo id. Con él emparejas cada respuesta con su pregunta, en el orden que sea. Mandaste tres peticiones con id 4, 5 y 6; la 6 puede llegar antes que la 4, y no pasa nada porque el id te dice cuál es cuál. Un Cliente real guarda un mapa de id a promesa pendiente, y cada línea que entra resuelve la promesa que le toca.

Hay un segundo tipo de mensaje que se parece a éste pero no trae id: la notificación. Es un aviso que no espera respuesta. session/update, el chorro de progreso que vas a ver en el capítulo 3, es una notificación: el Agente te cuenta qué está haciendo y no se detiene a esperar que le contestes. Si un mensaje sin id llega y no lo reconoces, el protocolo dice que lo ignores. Si llega uno con id y no lo reconoces, contestas "método no encontrado" con ese mismo id, para que quien preguntó no se quede colgado.

La respuesta a una petición trae el id y una de dos cosas: result si salió bien, o error si no. Nunca las dos.

Lo que se negocia

Ahora los params del initialize, que son la parte con contenido. Es una negociación en el sentido literal: cada lado dice lo que sabe hacer, y a partir de ahí ambos saben qué pueden pedirle al otro.

La versión

protocolVersion es un entero. Hoy es 1. La regla de la spec es corta: si el Agente soporta la versión que pediste, contesta esa misma; si no, contesta la más reciente que soporte, y entonces eres tú quien decide si puedes hablar en esa o cierras la conexión. Un Cliente que ignora ese campo de la respuesta y sigue adelante va a fallar más tarde, en algún método que cambió de forma, con un error que no se parece en nada a "versión incompatible".

Lo que ofrece el Cliente

clientCapabilities es la lista de lo que el Agente puede pedirte. En la petición de arriba pusiste todo en false a propósito: estabas en una terminal, sin nada que ofrecer.

fs.readTextFile y fs.writeTextFile habilitan, cada uno, un método: fs/read_text_file y fs/write_text_file. Si los pones en true, el Agente va a pedirte leer y escribir archivos, y tú tienes que contestar. Es la vía por la que el Agente ve el archivo como está en tu editor, sin guardar, y no como está en disco; ese detalle es el motivo de que existan estos métodos en vez de que el Agente abra los archivos por su cuenta.

terminal es un solo booleano y habilita la familia completa: terminal/create, terminal/output, terminal/wait_for_exit, terminal/kill y terminal/release. Todo o nada.

Lo que ofrece el Agente

agentCapabilities viene en la respuesta y te dice qué le puedes pedir tú.

loadSession te dice si existe session/load: si puedes retomar una conversación de otro día. El capítulo 9 vive de este booleano.

promptCapabilities describe qué tipos de contenido acepta el Agente dentro de una instrucción, además de texto: image, audio y embeddedContext, que es la manera de adjuntar un archivo completo en el cuerpo del mensaje. Si mandas una imagen a un Agente que declaró image: false, el error es tuyo, y el protocolo te dio la manera de saberlo antes.

mcpCapabilities dice por qué transportes acepta servidores MCP: http y sse. Lo vas a usar en el capítulo 8 y ahí se entiende por qué importa.

authMethods es una lista de formas de autenticarse. Si viene vacía, como arriba, no hace falta nada más y puedes abrir una sesión. Si trae algo, el Agente espera que llames authenticate con uno de esos métodos antes de session/new; si no lo haces, session/new contesta un error de autenticación requerida. La lista lleva un id por método y una descripción que puedes mostrarle al usuario tal cual.

agentInfo y clientInfo son tarjetas de presentación: name para máquinas, title para personas, version para cuando algo falla y hay que preguntar "¿con cuál hablabas?". Ninguno es obligatorio; ambos cuestan nada y ayudan mucho en un reporte de error.

Lo que pasa cuando mientes

Las capacidades son promesas, y el protocolo no tiene manera de verificarlas. Decir que sabes hacer algo que no sabes hacer produce fallas que no apuntan al lugar donde está el error, y por eso merecen un apartado.

El caso más instructivo es terminal: true. Es tentador ponerlo desde el primer día, "para después". Lo que ocurre es que el Agente, en cuanto necesita correr un comando, te manda un terminal/create y se queda esperando. Tu Cliente no tiene implementado ese método, así que contesta "método no encontrado", o peor, no contesta. Del lado del Agente, la herramienta de shell termina en estado failed. Lee bien lo que ves: el Agente te informa de un fallo en su herramienta, con un mensaje sobre un comando que no pudo correr. Todo apunta al Agente y a la consola de la caja. El error estaba en tu initialize, cinco minutos antes. Con write y edit no pasa nada, porque ésos no pasan por la terminal, así que el Agente funciona a medias y la sospecha tarda más en llegar al lugar correcto.

La versión del protocolo tiene su propia forma de venganza. Si el Agente contesta protocolVersion: 2 y tú lo ignoras, la conversación continúa. Los métodos que no cambiaron funcionan y los que sí cambiaron fallan uno por uno, cada uno con su propio error de forma, como si fueran problemas distintos.

Y está la mentira por omisión: no leer promptCapabilities y mandar lo que sea. Un Agente decente rechaza el bloque que no entiende; uno menos cuidadoso lo descarta en silencio y contesta como si nunca hubieras adjuntado la imagen.

La lección para el Cliente: declara sólo lo que ya implementaste, lee lo que el Agente declara y guárdalo, porque lo vas a consultar en cada método que sigue. La lección para el Agente, que aplicarás en Ghosty: declara con precisión, y cuando el Cliente te pida algo que no anunció, contesta con un error claro en vez de intentar adivinar.

Las mismas dos líneas, ahora en código

Hasta aquí el Cliente fuiste tú con la terminal. Vale la pena ver lo mismo en unas cuantas líneas de Node, sin dependencias, porque es el esqueleto sobre el que crecen los siguientes capítulos. Lo que sigue arranca el Agente, manda initialize, espera la respuesta y la imprime.

1import { spawn } from "node:child_process"; 2import { createInterface } from "node:readline"; 3 4// El Agente es un subproceso: le hablamos por stdin y lo leemos por stdout. 5const agent = spawn("goose", ["acp"], { stdio: ["pipe", "pipe", "inherit"] }); 6 7// Respuestas pendientes, por id. Cada línea que entra resuelve la suya. 8let nextId = 0; 9const pending = new Map<number, (result: unknown) => void>(); 10 11// Un mensaje por línea: sin Content-Length, sin indentación. 12const lines = createInterface({ input: agent.stdout }); 13lines.on("line", (line) => { 14 const message = JSON.parse(line); 15 if (message.id !== undefined && pending.has(message.id)) { 16 pending.get(message.id)!(message.error ?? message.result); 17 pending.delete(message.id); 18 } 19 // Las notificaciones (sin id) se ignoran por ahora; el capítulo 3 las escucha. 20}); 21 22function request(method: string, params: unknown) { 23 const id = nextId++; 24 agent.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); 25 return new Promise((resolve) => pending.set(id, resolve)); 26} 27 28const result = await request("initialize", { 29 protocolVersion: 1, 30 // Sólo lo que este Cliente ya sabe hacer: nada todavía. 31 clientCapabilities: { 32 fs: { readTextFile: false, writeTextFile: false }, 33 terminal: false, 34 }, 35 clientInfo: { name: "libro-acp", title: "Cliente del libro", version: "0.1.0" }, 36}); 37 38console.log(JSON.stringify(result, null, 2)); 39agent.stdin.end(); // cerrar el tubo termina al Agente 40

Tres detalles que ya están decididos ahí y que se mantienen en todo el libro. stderr va con inherit, para que los registros del Agente aparezcan en tu terminal y no se mezclen con los mensajes. El mapa pending es la implementación mínima del emparejamiento por id. Y las notificaciones caen al vacío a propósito: cuando llegue el momento de escucharlas, ese if se convierte en un despachador, y nada más cambia.

Corre el archivo. Vas a ver la misma respuesta que pegaste a mano, con el mismo id: 0. Guarda agentCapabilities en algún lado, porque en el capítulo siguiente abres una sesión, y para eso necesitas saber qué acepta el Agente que tienes enfrente.