cover

Un adaptador de 80 líneas para meter cualquier agente ACP a tu chat

author photo

Héctorbliss

@hectorbliss

GhostyCode y goose traen servidor de red: los levantas y ya hablan ACP por WebSocket. Los otros cincuenta agentes del registro oficial —Gemini CLI, Codex, Copilot, cline, qwen-code— no. Y no es descuido de nadie: el transporte de red de ACP sigue siendo un RFD en borrador. La especificación estable define un solo transporte, stdio, con el cliente lanzando al agente como proceso hijo.

Cruzando la red no hay padre ni hijo. Ochenta líneas de Node restituyen esa relación: son el padre del agente de un lado y el WebSocket del otro. Con eso, cualquier agente del registro entra al chat de tu equipo.

Vamos a montarlo con Gemini CLI dentro de una microVM y a conectarlo a Ghosty Teams.

El adaptador en medio: WebSocket de un lado, stdin y stdout del otro, hacia Gemini CLI

El adaptador

Guarda esto como adaptador.mjs. Node trae WebSocket cliente desde la v22, pero no servidor, así que la única dependencia es ws.

Primero el servidor y quién puede entrar:

/health va sin credencial porque es lo que consulta quien da de alta la caja: sirve para saber que llegaste al lugar correcto antes de autenticarte. El token se comprueba en el upgrade, que es un GET y por tanto CORS no lo cubre.

Después el cruce. Cada conexión levanta su propio agente:

Ese búfer de pendiente es obligatorio. stdout llega en pedazos arbitrarios, y sin acumular hasta el salto de línea vas a partir un mensaje a la mitad y mandar JSON roto por el socket. Con mensajes cortos parece que funciona; se rompe cuando el agente empieza a producir respuestas largas.

El stderr no es del protocolo: va al log del adaptador y nunca al socket.

kill(pid) deja dos huérfanos vivos; kill(-pid) apaga el grupo entero

Y ahí está la trampa que cuesta una tarde: agente.kill() mata el proceso, no su descendencia. Los CLI de agentes suelen ser un script que arranca otros procesos, y gemini deja dos vivos. Con detached: true el agente estrena grupo propio, y el - de process.kill(-pid) mata el grupo entero. El SIGKILL de respaldo es para el agente ocupado que tarda en atender el SIGTERM, o que no lo atiende nunca.

Sin eso quedan dos procesos por cada conversación, y en una caja que vive semanas se comen la memoria sin que nadie sepa por qué.

¿Y si un solo agente atendiera a todos?

Cada conexión levanta su propio proceso. La otra opción es compartir uno, y cuesta más de lo que parece.

Cada mensaje de ACP lleva un número para saber qué respuesta corresponde a qué pregunta. El problema es que cada cliente empieza a contar desde 1. Si dos hilos escriben al mismo agente, los dos mandan su id 1, y cuando llega la respuesta —también con id 1— nadie sabe a quién devolvérsela.

Sin tabla, la respuesta id 1 no dice de quién es; con tabla, cada quien recibe la suya

Se resuelve con una tabla: renumeras cada mensaje que entra, guardas de quién era, y traduces de vuelta al salir. Unas treinta líneas más, contando que los permisos viajan al revés —del agente al cliente— y también hay que enrutarlos.

Ganas un proceso en vez de N. Pierdes el aislamiento: un solo agente es un solo workspace para todos los que le escriban, y si se cae, se cae para todos.

Por eso aquí va uno por conexión: se cierra el socket, muere el proceso, y no hay nada que desenredar.

Montarlo en la caja

Nunca entras a la caja: /exec corre un comando adentro y devuelve su salida, /bg deja algo corriendo. Necesitas una llave de EasyBits de Dashboard → Developer y una de Google AI Studio para el modelo.

Una caja, con vida suficiente para trabajar:

Nace con 30 minutos, así que ese extend a cuatro horas va antes de invertirle trabajo. El detalle de cada llamada está en el post anterior.

Dentro: el agente, la dependencia del adaptador, y el directorio donde va a trabajar.

/data/work no es capricho: es el cwd con el que Ghosty Teams abre la sesión, y Gemini valida que exista. Si falta, el primer turno muere con Directory does not exist: /data/work. El LEEME.txt es para tener algo que pedirle; en su lugar puedes clonar un repo si quieres darle trabajo de verdad.

Ahora el archivo del adaptador. Sin SSH y sin scp, viaja en el mismo /exec, codificado en base64 para que ni las comillas ni los saltos de línea peleen con el JSON:

Ese node --check comprueba que el archivo llegó entero. Un base64 truncado produce un error de sintaxis al arrancar que parece cualquier otra cosa. (Si vas a editar adentro seguido, EasyBits también abre SSH con sandbox_ssh_enable, en plantillas que declaren el puerto 22.)

Gemini CLI dentro de una caja va por llave: su login con Google abre un navegador, y ahí no hay ninguno. Lo levantas con /bg, que es la única forma de dejar algo corriendo — un nohup … & dentro de /exec muere con el comando que lo lanzó:

Y a la calle:

El token no se prueba con curl: la puerta está en el upgrade, que curl no hace. Se ve al abrir el socket, y sin la cabecera el cliente recibe un 401 crudo en vez de una conexión.

Conectarlo a Ghosty Teams

En Ajustes → Agentes → Agregar agente → Por ACP va la misma URL con esquema de socket y la ruta del protocolo — echo "${CAJA/https/wss}/acp" te la imprime lista para pegar—, el $ACP_TOKEN en su campo, un @handle y listo.

Al darle Probar, Teams abre el WebSocket y hace initialize — el mismo handshake del primer mensaje real — y te muestra lo que el agente responde de sí mismo:

Si ves eso, el circuito completo funciona. Escríbele en un hilo y contesta desde la caja, con las herramientas del espacio a la mano.

El modelo vive en la sesión

Gemini CLI no declara modelSelection en sus agentCapabilities, así que parece que el modelo no se elige. Pero la respuesta de session/new trae un catálogo:

El default es auto: el CLI decide por turno entre el pro y el flash. Cuál te tocó lo dice el resultado del session/prompt, que viene con la cuenta:

Para fijarlo, una bandera más al final del /bg — el adaptador pasa los argumentos tal cual al agente. Tumbas el proceso viejo y levantas con el modelo que quieras:

Ojo con ese pkill: el patrón lleva corchetes a propósito. pkill -f adaptador.mjs mataría también al shell que /exec levanta para correrlo, porque su propia línea de comando contiene esa cadena — se suicida antes de hacer nada, y en absoluto silencio.

Importa si el agente va a buscar en la web: el grounding de la familia 2.5 cuesta $35 por cada 1,000 búsquedas contra $14 de la 3.x, y con auto no eliges cuál te toca.

Lo que este adaptador no hace

Ochenta líneas alcanzan para un agente por conexión y poco más. Faltan cosas que un servidor de verdad sí trae: multiplexar sesiones sobre un proceso, límites de tamaño de frame, reconexión con reanudación de sesión, y el Acp-Connection-Id que el RFD define para identificar conexiones.

Justo eso implementa el SDK oficial (agent-client-protocol-http), y por eso GhostyCode y goose no necesitan adaptador: el servidor ya viene adentro. Si el agente te da igual y lo que quieres es el montaje sólido, empieza por ahí y sáltate este archivo:

Este adaptador es para el resto: los cincuenta agentes que hablan solo stdio y que, mientras el RFD no aterrice, necesitan a alguien que les preste el cable. El día que lo adopten, el archivo se borra.

meta cover

Integrando MongoDB Vector Search con Effect-TS: Una Guía para Principiantes

Checa este otro Post

meta cover

Entendiendo yield*: El Operador de Delegación en JavaScript y Effect

Checa este otro Post

¡Nuevo curso!

Animaciones web con React + Motion 🧙🏻