Construí aicodebox como una abstracción, una sola imagen base, un contrato AgentAdapter, enchufas el CLI de cualquier agente de código y te llevas gratis la superficie de API/MCP/Telegram/cron. Bonita teoría. Las teorías no valen una mierda hasta que has cableado de verdad un segundo binario de agente, completamente distinto, al contrato y has mirado por dónde sangra. Así que agarré pi-coding-agent, un CLI que no escribí y no controlo, y lo forcé a pasar por el adaptador. Eso es pibox. No es una imagen insignia llena de funciones, es la prueba de que la abstracción no es mentira, y ahora es la referencia de la que se copia cualquier otra imagen hija, incluida la de Claude Code.
Si ya leíste el post de aicodebox conoces el discurso: modos, adaptadores, REST, compatibilidad con OpenAI, MCP, Telegram, cron, todo en la capa base, todo gratis una vez que escribes un adaptador. No lo voy a reescribir aquí. Este post va de lo que costó de verdad atornillar un binario de agente de terceros a ese contrato sin hacer trampas, y de lo pequeña que acabó siendo la superficie una vez que dejé de añadir mierdas que no sostenían nada.
Lo Que pi No Te Da
pi-coding-agent es un CLI perfectamente decente. También está, como todos los CLI de agente del planeta, hecho para un humano sentado en una terminal, no para un proceso de servidor que necesita metadatos estructurados de vuelta. Cablearlo en aicodebox significó rodear cada una de las suposiciones muy razonables y muy de terminal de pi:
- Dos modos de salida, uno inútil para una API. pi tiene
--mode texty--mode json. El modo texto te da las palabras del asistente y absolutamente nada más, sin id de sesión, sin consumo, sin eventos por turno. Vale para un humano, inútil para una ruta que tiene que facturar tokens y reanudar sesiones. - Cero validación nativa de esquema JSON. pi no tiene flag
--schema, no tiene modo de salida estructurada. Solo habla. - Sin soporte nativo de
ANTHROPIC_BASE_URL. La propia documentación de pi dice «usa models.json», es decir, no va a leer la variable de entorno por la que un proxy compatible con Anthropic (Z.AI, OpenRouter, tu propio gateway) espera funcionar. - Un proveedor integrado que te secuestra el enrutado en silencio. El proveedor
zaide pi se apropia automáticamente de cualquier nombre de modeloglm-*, lo que significa que una petición que debía pasar por tu sobreescritura deANTHROPIC_BASE_URLpuede acabar redirigida calladamente a un proveedor que no pediste. - Sin soporte de primera clase de MCP desde la config del workspace como lo tiene Claude Code, o sea, sin recogida automática de un
.mcp.jsondel workspace.
Nada de eso es culpa de pi, es un CLI, hace cosas de CLI. Pero «no es culpa del agente» no te da una API. Alguien tiene que traducir. Ese alguien es una sola clase de Python.
Tres Variables de Entorno Son Todo el Anclaje
Aquí está la parte que de verdad le gana a pibox el título de «referencia». Desnuda el Dockerfile hasta lo que pone de específico para pibox y te salen exactamente tres variables de entorno:
ENV AICODEBOX_ADAPTER=pibox.adapter:PiAdapter
AICODEBOX_AGENT_BINARY=pi
PIBOX_IMAGE_VARIANT=minimalYa está. Esa es toda la superficie declarativa que necesita una imagen hija: apunta AICODEBOX_ADAPTER a un module:ClassName importable, dile a la base a qué nombre de binario tirar, y toda la maquinaria de modos, REST, compatibilidad con OpenAI, MCP, Telegram, cron, cobra vida a su alrededor. PIBOX_IMAGE_VARIANT no forma parte de ese contrato en absoluto, solo etiqueta en cuál de las dos imágenes publicadas estás metido, la mínima o la full. Todo lo demás en el Dockerfile o instala el binario del agente, o instala el paquete Python que implementa el adaptador, o es branding. Si estás construyendo tu propia imagen hija y el Dockerfile necesita una tercera variable de entorno para que el agente arranque, estás haciendo algo que el contrato no pretendía obligarte a hacer, vete a releer la clase AgentAdapter de la base.
El resto de la build es aburrida a propósito: npm install -g @earendil-works/[email protected], fijado, no @latest, porque «funciona hoy» y «funciona dentro de seis meses» no son la misma afirmación, y luego uv pip install --system --break-system-packages --no-deps /opt/pibox para el paquete de adaptador en sí (--no-deps porque aicodebox ya está en la imagen base y volver a resolverlo es trabajo tirado).
El Adaptador: build_argv Es Obligatorio, Todo lo Demás Es Una Elección
La clase AgentAdapter de la base tiene exactamente un método que lanza NotImplementedError si no lo sobreescribes: build_argv. Todos los demás ganchos, validate, translate_auth, parse_output, parse_events, parse_stream_event, interactive_argv, passthrough_argv, auth_paths, traen un valor por defecto que funciona. PiAdapter los sobreescribe todos igualmente, porque el CLI de pi es lo bastante raro como para que los valores por defecto produzcan basura. Esto es lo que te compra cada uno de verdad, verificado contra pibox/pibox/adapter.py:
build_argv(obligatorio): invoca siemprepi -p --mode json, nunca--mode text, precisamente para que el adaptador reciba el flujo completo de eventos de sesión en lugar de prosa pelada. El manejo de sesión se ramifica en tres:--session <id>al reanudar,--no-sessionpara efímero,--continueen el resto.validate(opcional, la base solo compruebaoutput_format): rechaza un valor dethinkingfuera deoff/minimal/low/medium/high/xhigh, y rechazatools_allowlistcombinado conno_toolscomo tontería mutuamente excluyente.translate_auth(opcional, el valor por defecto de la base no hace nada): sobreescrito igualmente, sigue sin devolver nada, porque pi lee de forma nativaANTHROPIC_API_KEY/OPENAI_API_KEY/OPENROUTER_API_KEY/GEMINI_API_KEY/ZAI_API_KEY. La sobreescritura existe para documentar ese hecho en el código, no para cambiar el comportamiento.parse_output(opcional, el valor por defecto de la base solo limpia stdout como texto plano): recorre el NDJSON de pi, sacandosessionpara el id de sesión,message_endpara el texto del asistente y el consumo, yturn_endcomo recurso para el consumo. Aquí es también donde se rellenaprovider_error: cuando un turno de asistente llevastopReason=errormás unerrorMessage, la manera que tiene pi de reportar un rechazo aguas arriba, un rate limit o un fallo de auth, el adaptador lo captura y lo reenvía enRunResult.provider_errorpara que la ruta de OpenAI pueda devolver un400de verdad en vez de un200con texto vacío.parse_events(opcional, el valor por defecto de la base devuelve una lista vacía): decodifica como JSON cada línea NDJSON para el modo de salidajson-verbose; las líneas malformadas se tiran con un aviso, no con un crash.parse_stream_event(opcional, el valor por defecto de la base trata cada línea como un delta de texto crudo): decodifica el flujomessage_update.assistantMessageEventde pi, reenvía al cable solotext_delta, y se traga en silencio los deltasthinking_*y los de uso de herramientas para que el razonamiento interno del modelo no se filtre nunca al campocontentcompatible con OpenAI.auth_paths(opcional, el valor por defecto de la base no devuelve nada que persistir): lista el estado real de pi,~/.pi/agent/auth.json,settings.json,models.jsony el directoriosessions, para que los tokens OAuth y el historial de sesiones sobrevivan a undocker starten vez de evaporarse.
Hay también un hack metido dentro de build_argv del que no estoy orgulloso pero que defenderé sin reservas: si ANTHROPIC_BASE_URL está puesta y quien llama no ha elegido ya un proveedor en extra_args, el adaptador inyecta a la fuerza --provider anthropic. ¿Por qué? Porque el proveedor integrado zai de pi se apropia automáticamente de cualquier nombre de modelo glm-* y se salta tu sobreescritura de base URL por completo. Sin el flag forzado, apuntar pibox a un proxy compatible con Z.AI y pedir un modelo glm-4.6 ignora tu proxy en silencio y habla directamente con lo que el proveedor zai de pi crea que debe. Ese es el tipo de bug que le cuesta a alguien una tarde y un ticket de soporte antes de que nadie se dé cuenta de que el tráfico nunca tocó el proxy.
Modo Esquema: pi No Tiene Validación Nativa, Así Que Es Un Añadido al Prompt de Sistema
pi no valida esquemas JSON. No tiene ningún flag para eso. Así que cuando una petición lleva jsonSchema, la única jugada del adaptador es añadir una directiva al prompt de sistema diciéndole al modelo, en inglés llano, «responde con un único documento JSON conforme a este esquema, sin prosa, sin vallas de código», y luego pasarle el esquema en JSON crudo con ello. Toda la validación de verdad, parsear el resultado, comprobarlo contra el esquema, reintentar hasta tres veces con un prompt correctivo cuando falla, ocurre en la capa base de aicodebox, no en pibox. El único trabajo del adaptador es empujar al modelo hacia el cumplimiento; no tiene voz ni voto en si el modelo cumple de verdad.
Esa separación importa por lo que llegó después. El ayudante de reintento de esquema de la base se fue volviendo más listo progresivamente sin que cambiara ni una línea del código de adaptador de pibox, y los comentarios del propio changelog del Dockerfile se leen como un diario de comportamiento de la base evolucionando bajo un contrato de adaptador completamente estable: reintentos que reformulan la tarea original en vez de solo el error (para que un esquema que pide elegir de un enum grande no reintente a ciegas), workspaces efímeros por petición para que una petición de 100k tokens que necesita tres reintentos pague unos 1,5k tokens de sobrecoste correctivo en vez de reproducir los 100k enteros tres veces, y, lo más reciente, stream:true combinado con llamada a herramientas o modo esquema ya no devuelve un 400 seco. Ahora calcula la respuesta completa sin streaming y la reproduce como un único flujo SSE con buffer: un chunk de rol, un delta de content o de tool_calls, un chunk de cierre, [DONE]. El chat normal sigue haciendo streaming token a token. Nada de eso tocó pibox/pibox/adapter.py. Ese es todo el sentido del contrato, la imagen hija no tiene derecho a saber ni a que le importe que la base se volviera más lista por debajo.
La Extensión mcp-bridge: Darle a pi el Formato de Config de Otro
pi no lee de forma nativa un .mcp.json del workspace como hace Claude Code. pibox trae una extensión de TypeScript, pibox/extensions/mcp-bridge/index.ts, que lo hace por pi: al arrancar la sesión lee el .mcp.json del workspace usando el esquema de claude-code (mcpServers.<name>.{command,args,env}), levanta cada servidor por stdio, llama a listTools(), y registra cada herramienta en pi bajo un nombre saneado mcp__<server>__<tool> vía pi.registerTool(). Las llamadas a herramientas se reenvían al servidor MCP y el resultado vuelve por el mismo canal que usan las herramientas integradas de pi, el modelo no nota la diferencia.
El npm install de esa extensión ocurre en tiempo de build, RUN cd /opt/pibox/extensions/mcp-bridge && npm install --omit=dev --no-audit --no-fund, no en el primer arranque del container. Es una elección deliberada: nadie quiere que su primera ejecución de agente se atasque en un resolve de npm. Un script init.d, pibox/init.d/10-pi-extensions.sh, engancha la ruta de la extensión preinstalada en el array extensions de ~/.pi/agent/settings.json en el primer arranque, de forma idempotente, mediante un merge con jq que deduplica con unique para que volver a ejecutarlo no acumule entradas duplicadas.
También hay ahí dentro una carrera en el apagado que vale la pena señalar porque es el tipo de bug que solo aparece en producción: pi -p se queda colgado después de imprimir su respuesta final si los subprocesos de servidor MCP levantados siguen manteniendo el bucle de eventos abierto. La extensión escucha session_shutdown, hace competir el cierre de cada cliente MCP contra un timeout de 2 segundos, y luego, cinturón y tirantes, llama a la fuerza a process.exit(0) medio segundo después porque algunas versiones de Node mantienen el bucle vivo incluso después de que close() resuelva. Sin eso, una llamada de API de un solo tiro se quedaría ahí sentada hasta que algo externo la matara.
pibox-entrypoint: 17 Alias y una Regeneración de Config al Arrancar
pibox-entrypoint.sh existe puramente para darle a pibox su propia superficie de variables de entorno con marca sin duplicar nada de la lógica de la base. Define una lista de 17 sufijos, API_MODE, API_MODE_PORT, API_MODE_TOKEN, TELEGRAM_MODE, TELEGRAM_MODE_TOKEN, TELEGRAM_MODE_CONFIG, TELEGRAM_MODE_OVERRIDES, CRON_MODE, CRON_MODE_FILE, CRON_MODE_HISTORY_DIR, MCP_MODE, MCP_MODE_PORT, MCP_MODE_TOKEN, WORKSPACE, AVAILABLE_MODELS, AVAILABLE_EFFORTS, CONTAINER_NAME, y para cada uno, si PIBOX_<suffix> está puesta y AICODEBOX_<suffix> no, copia el valor. AICODEBOX_* gana si están las dos, así que los usuarios avanzados no quedan excluidos de los nombres subyacentes. Las dos variables de selección de adaptador, ADAPTER y AGENT_BINARY, están excluidas deliberadamente de esa lista; esas las fija el Dockerfile y no son algo que un usuario deba poder sobreescribir en tiempo de ejecución.
El entrypoint también ejecuta setup-provider-env.sh en cada arranque, no solo en el primero, y ese «cada arranque» es el arreglo de un bug real, no una elección de estilo. El script regenera la entrada de proveedor anthropic de pi en ~/.pi/agent/models.json a partir de ANTHROPIC_BASE_URL / ANTHROPIC_MODEL cada vez que arranca el container. Antes corría vía init.d, que por diseño solo se dispara una vez por vida de container, bien para un container desechable, roto en el momento en que alguien bind-montea ~/.aicodebox o ~/.pi como volumen persistente, porque entonces el marcador de init sobrevive a la reconstrucción y una ANTHROPIC_BASE_URL cambiada ya no vuelve a llegar nunca a models.json, en silencio. Moverlo al entrypoint significa que la config de base URL se refresca en cada arranque, con volumen persistente o sin él.
v0.16.0: la ruta de Anthropic es ahora solo una de cuatro
Todo lo de arriba está escrito alrededor de una sola forma, doblar el proveedor anthropic de pi hacia el endpoint al que apuntes ANTHROPIC_BASE_URL. Eso sigue funcionando, y sigue siendo lo que corre si es todo lo que pones. Pero ahora es el camino heredado.
La v0.16.0 hizo genérico el proveedor. Cinco variables describen cualquier endpoint HTTP: PIBOX_PROVIDER_BASE_URL, PIBOX_PROVIDER_API, PIBOX_PROVIDER_API_KEY, PIBOX_PROVIDER_MODEL, PIBOX_PROVIDER_NAME. La de API elige la forma del cable, y hay cuatro: openai-completions, openai-responses, anthropic-messages, google-generative-ai. Lo que cubre LiteLLM y Z.AI sin fingir que ninguno de los dos es Anthropic.
El hack de inyección forzada en build_argv creció en consecuencia. Ahora es un selector de dos ramas: usa el proveedor configurado si lo hay, si no cae de vuelta a anthropic, e inyecta --model junto con él.
Pon PIBOX_PROVIDER_* y ANTHROPIC_BASE_URL al mismo tiempo y pibox sale en vez de elegir uno. Bien. Dos esquemas de enrutado a medio configurar que se contradicen es exactamente el fallo que quieres ruidoso y al arrancar, no callado y tres llamadas a herramientas más adentro.
Un detalle que vale la pena robar: la clave de aguas arriba solo vive en el entorno del proceso de Pi. Lo que aterriza en models.json es una referencia del tipo $OPENAI_API_KEY, no el secreto en sí, así que el fichero de config en disco sigue siendo aburrido si alguien lo lee.
v0.18.0: por fin tiene un wrapper
Todo lo que va debajo era antes la única forma de ejecutarlo: líneas de docker run escritas a mano con los montajes y el entorno deletreados cada vez. claudebox y codexbox tenían los dos un wrapper de host y un instalador. pibox no, porque era el hijo sin brillo y nadie se puso a ello.
Ahora tiene uno. install.sh te pone un comando pibox en el PATH y el wrapper se ocupa de la fontanería de containers, así que ejecutarlo en un directorio es solo pibox. Diez variables lo dirigen: PIBOX_DATA_DIR, PIBOX_STATE_DIR y PIBOX_SSH_DIR para dónde vive el estado, PIBOX_IMAGE y PIBOX_FULL para qué imagen, PIBOX_DETACH para ejecuciones en segundo plano, PIBOX_ENV_* y PIBOX_MOUNT_* para pasar entorno y montajes, y PIBOX_INSTALL_DIR con PIBOX_BIN_NAME para decidir dónde aterriza el comando y cómo se llama.
La v0.17.0 le dio también una variante psyb0t/pibox:latest-full construida sobre aicodebox:v0.15.0-full, que es a donde se mudó el toolchain compartido de Go, Node, Python, clientes de bases de datos y ops. pibox dejó de construir nada de eso él mismo.
Y las cajas pueden llamarse unas a otras
Instala pibox, claudebox y codexbox en el mismo directorio y cada wrapper monta las otras dos en solo lectura en /usr/local/bin/<name>, así que pi puede pasarle una tarea a otro agente sin salir de su sesión. Las ejecuciones anidadas llevan un contexto de host versionado, AICODEBOX_LAUNCH_CONTEXT_VERSION=1 más AICODEBOX_HOST_HOME, AICODEBOX_HOST_WORKSPACE, AICODEBOX_HOST_WRAPPER_DIR y un home y una ruta de wrapper por agente, porque una ruta dentro de un container no dice nada del host. Una ejecución anidada se salta las escrituras de ficheros de auth que hace una de primer nivel, así que un hijo no puede reescribir las credenciales de quien lo llamó. AICODEBOX_ENV_* y AICODEBOX_MOUNT_* reenvían a todas las cajas de golpe, y AICODEBOX_MANAGED_INSTALL=1 es una instalación no interactiva que se niega a pisar una clave SSH existente.
El bug que convertía la lista de modelos en una mentira
Merece mención porque la sección de arriba promete algo que la v0.16.0 no llegó a entregar. Podías listar varios modelos en PIBOX_AVAILABLE_MODELS, pero solo el nombrado en PIBOX_PROVIDER_MODEL quedaba registrado con el proveedor. Pide cualquiera de los otros y la ejecución moría con Stream ended without finish_reason, que no te dice nada de la causa real. La v0.16.2 registra todos los modelos anunciados, así que la lista significa lo que dice. El arranque ahora también avisa cuando PIBOX_PROVIDER_BASE_URL y PIBOX_PROVIDER_API no coinciden sobre qué protocolo estás hablando, que es la otra manera de conseguir un fallo confuso tres llamadas más adentro.
Uno más heredado de la base: eventMode en una ejecución controla la retención de eventos con independencia de si pediste un esquema. full te devuelve los registros nativos del proveedor intactos en lugar de la versión resumida.
Uso
De un solo tiro, sin servidor:
docker run --rm
-e ANTHROPIC_AUTH_TOKEN=your-token
-e ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
-e ANTHROPIC_MODEL=glm-4.6
psyb0t/pibox:latest
-p "list the files in /workspace"Servidor de API, el mismo aliasado de variables de entorno que aparece en cada imagen de psyb0t:
docker run -d --network host
-e PIBOX_API_MODE=1
-e PIBOX_API_MODE_TOKEN=your-secret
-e PIBOX_AVAILABLE_MODELS=glm-4.6,glm-4.5-air
-e ANTHROPIC_AUTH_TOKEN=your-token
-e ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
-e ANTHROPIC_MODEL=glm-4.6
-v "$PWD/workspace:/workspace"
psyb0t/pibox:latestO constrúyela tú mismo sobre la base fijada:
# derives VERSION from pibox/pyproject.toml, pulls
# psyb0t/aicodebox:v0.14.0, tags :v<VERSION> and :latest
make buildPIBOX_API_MODE=1 levanta FastAPI en :8080 y se niega a arrancar sin PIBOX_AVAILABLE_MODELS puesta, porque no hay un valor por defecto sensato, ya que pi puede conducir la lista de modelos de cualquier proveedor, y una lista de modelos vacía en silencio es peor que un fallo de arranque. Expone /run, /openai/v1/chat/completions, /files/*, y, cuando PIBOX_MCP_MODE=1, /mcp montado en el mismo puerto. Los modos de Telegram y cron reciben el mismo tratamiento cubierto en el post de aicodebox; vete a leer ese si quieres la matriz de modos en vez de la fontanería específica de pi.
El Hijo Sin Brillo
pibox no es la imagen hija llamativa. Es la que demuestra que el contrato de la base sobrevive al contacto con un binario de agente que dio por hecho que un humano, y no una API, iba a leer su salida. Dos variables de entorno anclan el adaptador, una tercera solo etiqueta la imagen, una clase de Python traduce el CLI con forma de terminal de pi a algo que la base sabe conducir, y el trabajo propio de funciones de la base, streaming, reintentos de esquema, llamada a herramientas, aterrizó por debajo sin que se moviera ni una línea de código de adaptador. Eso es lo que se supone que significa «implementación de referencia»: no la más grande, la que demuestra que la superficie más pequeña sigue funcionando.
Si quieres el agente más completo, la misma base, Claude Code en lugar de pi, ese es el post de claudebox. Si quieres ver al hermano de pibox con sabor a Z.AI corriendo como proveedor de verdad dentro de un stack más grande en vez de en solitario, eso está en el post de aigate, siendo pibox-zai uno de los proveedores a los que enruta aigate. El código está en GitHub. Es la imagen menos interesante de la familia y la primera que señalaría si estás escribiendo la tuya.
Cómo Instalarla En Tu Agente
La imagen menos interesante de la familia trae igualmente la misma ruta de instalación que el resto. Todo lo que hay bajo .agents/ está catalogado en un solo marketplace, así que son dos comandos:
claude plugin marketplace add psyb0t/agents
claude plugin install pibox@psyb0tCodex usa el mismo marketplace con un verbo distinto, codex plugin add pibox@psyb0t, porque no existe codex plugin install. También encuentra el skill por su cuenta en un checkout del repo, ya que escanea .agents/skills/ de forma nativa sin nada instalado en absoluto. Además ahora está listado en el MCP Registry oficial, así que un cliente que resuelva servidores desde ahí puede encontrarlo sin que le den una URL.