pibox: Trois Variables d’Environnement Ont Suffi à Brancher pi-coding-agent sur aicodebox

J’ai construit aicodebox comme une abstraction, une seule image de base, un contrat AgentAdapter, tu y branches le CLI de n’importe quel agent de code et tu obtiens gratuitement la surface API/MCP/Telegram/cron. Jolie théorie. Les théories ne valent que dalle tant que tu n’as pas réellement câblé un deuxième binaire d’agent, complètement différent, sur le contrat, et regardé où ça saigne. Alors j’ai attrapé pi-coding-agent, un CLI que je n’ai pas écrit et que je ne contrôle pas, et je l’ai forcé à travers l’adaptateur. C’est ça, pibox. Ce n’est pas une image phare bourrée de fonctionnalités, c’est la preuve que l’abstraction n’est pas un mensonge, et c’est maintenant la référence sur laquelle toutes les autres images enfants sont recopiées, y compris celle de Claude Code.

Si tu as déjà lu le post sur aicodebox tu connais le pitch: des modes, des adaptateurs, du REST, de la compat OpenAI, MCP, Telegram, cron, tout dans la couche de base, tout gratuit une fois l’adaptateur écrit. Je ne le retape pas ici. Ce post parle de ce qu’il a réellement fallu pour boulonner un binaire d’agent tiers sur ce contrat sans tricher, et de la petitesse finale de la surface une fois que j’ai arrêté d’ajouter de la merde qui ne portait rien.

Ce Que pi Ne Te Donne Pas

pi-coding-agent est un CLI très correct. Il est aussi, comme tous les CLI d’agent de la planète, fait pour un humain assis devant un terminal, pas pour un processus serveur qui a besoin de métadonnées structurées en retour. Le câbler dans aicodebox voulait dire contourner chacune des hypothèses très raisonnables et très orientées terminal de pi:

  • Deux modes de sortie, un inutilisable pour une API. pi a --mode text et --mode json. Le mode texte te donne les mots de l’assistant et absolument rien d’autre, pas d’id de session, pas d’usage, pas d’événements par tour. Bien pour un humain, inutile pour une route qui doit facturer des tokens et reprendre des sessions.
  • Zéro validation native de schéma JSON. pi n’a pas de flag --schema, pas de mode de sortie structurée. Il parle, c’est tout.
  • Aucun support natif d’ANTHROPIC_BASE_URL. La doc de pi dit « utilise models.json », c’est-à-dire qu’il ne lira pas la variable d’environnement par laquelle un proxy compatible Anthropic (Z.AI, OpenRouter, ta propre gateway) s’attend à fonctionner.
  • Un provider intégré qui détourne silencieusement ton routage. Le provider zai de pi s’approprie automatiquement n’importe quel nom de modèle glm-*, ce qui veut dire qu’une requête censée passer par ta surcharge d’ANTHROPIC_BASE_URL peut être rerouté en douce vers un provider que tu n’as pas demandé.
  • Aucun support de première classe du MCP depuis la config du workspace comme l’a Claude Code, c’est-à-dire aucune prise en compte automatique d’un .mcp.json du workspace.

Rien de tout ça n’est la faute de pi, c’est un CLI, il fait des trucs de CLI. Mais « ce n’est pas la faute de l’agent » ne te donne pas une API. Quelqu’un doit traduire. Ce quelqu’un est une seule classe Python.

Trois Variables d’Environnement, C’est Tout l’Ancrage

Voilà la partie qui vaut vraiment à pibox le titre de « référence ». Déshabille le Dockerfile jusqu’à ce qu’il pose de spécifique à pibox et tu obtiens exactement trois variables d’environnement:

ENV AICODEBOX_ADAPTER=pibox.adapter:PiAdapter 
    AICODEBOX_AGENT_BINARY=pi 
    PIBOX_IMAGE_VARIANT=minimal

C’est tout. Voilà toute la surface déclarative dont une image enfant a besoin: pointe AICODEBOX_ADAPTER sur un module:ClassName importable, dis à la base quel nom de binaire lancer, et toute la machinerie de modes, REST, compat OpenAI, MCP, Telegram, cron, s’éveille autour. PIBOX_IMAGE_VARIANT ne fait pas partie de ce contrat du tout, il étiquette juste dans laquelle des deux images publiées tu te trouves, minimale ou full. Tout le reste du Dockerfile installe soit le binaire de l’agent, soit le paquet Python qui implémente l’adaptateur, soit du branding. Si tu construis ta propre image enfant et que le Dockerfile a besoin d’une troisième variable d’environnement pour faire tourner l’agent, tu fais quelque chose que le contrat n’était pas censé t’imposer, va relire la classe AgentAdapter de la base.

Le reste du build est ennuyeux à dessein: npm install -g @earendil-works/[email protected], épinglé, pas @latest, parce que « ça marche aujourd’hui » et « ça marche dans six mois » ne sont pas la même affirmation, puis uv pip install --system --break-system-packages --no-deps /opt/pibox pour le paquet d’adaptateur lui-même (--no-deps parce qu’aicodebox est déjà dans l’image de base et que le re-résoudre est du travail perdu).

L’Adaptateur: build_argv Est Obligatoire, Tout le Reste Est un Choix

La classe AgentAdapter de la base a exactement une méthode qui lève NotImplementedError si tu ne la surcharges pas: build_argv. Tous les autres hooks, validate, translate_auth, parse_output, parse_events, parse_stream_event, interactive_argv, passthrough_argv, auth_paths, viennent avec un défaut fonctionnel. PiAdapter les surcharge quand même tous, parce que le CLI de pi est assez bizarre pour que les défauts produisent de la bouillie. Voilà ce que chacun t’achète réellement, vérifié contre pibox/pibox/adapter.py:

  • build_argv (obligatoire): invoque toujours pi -p --mode json, jamais --mode text, précisément pour que l’adaptateur reçoive le flux complet d’événements de session au lieu de prose nue. La gestion de session se branche en trois: --session <id> à la reprise, --no-session pour l’éphémère, --continue sinon.
  • validate (optionnel, la base ne vérifie que output_format): rejette une valeur de thinking hors de off/minimal/low/medium/high/xhigh, et rejette tools_allowlist combiné à no_tools comme une absurdité mutuellement exclusive.
  • translate_auth (optionnel, le défaut de la base ne fait rien): surchargé quand même, ne renvoie toujours rien, parce que pi lit nativement ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / ZAI_API_KEY. La surcharge existe pour documenter ce fait dans le code, pas pour changer le comportement.
  • parse_output (optionnel, le défaut de la base se contente de nettoyer stdout en texte brut): parcourt le NDJSON de pi, en tirant session pour l’id de session, message_end pour le texte de l’assistant et l’usage, et turn_end comme repli d’usage. C’est aussi là que provider_error se remplit: quand un tour d’assistant porte stopReason=error plus un errorMessage, la façon qu’a pi de signaler un rejet en amont, un rate limit ou un échec d’auth, l’adaptateur le capture et le transmet sur RunResult.provider_error pour que la route OpenAI puisse renvoyer un vrai 400 au lieu d’un 200 au texte vide.
  • parse_events (optionnel, le défaut de la base renvoie une liste vide): décode en JSON chaque ligne NDJSON pour le mode de sortie json-verbose; les lignes malformées sont jetées avec un avertissement, pas un crash.
  • parse_stream_event (optionnel, le défaut de la base traite chaque ligne comme un delta de texte brut): décode le flux message_update.assistantMessageEvent de pi, ne transmet sur le fil que text_delta, et avale en silence les deltas thinking_* et ceux d’usage d’outils pour que le raisonnement interne du modèle ne fuite jamais dans le champ content compatible OpenAI.
  • auth_paths (optionnel, le défaut de la base ne renvoie rien à persister): liste l’état réel de pi, ~/.pi/agent/auth.json, settings.json, models.json et le dossier sessions, pour que les tokens OAuth et l’historique de sessions survivent à un docker start au lieu de s’évaporer.

Il y a aussi un hack glissé dans build_argv dont je ne suis pas fier mais que je défendrai sans réserve: si ANTHROPIC_BASE_URL est posée et que l’appelant n’a pas déjà choisi un provider dans extra_args, l’adaptateur injecte de force --provider anthropic. Pourquoi? Parce que le provider intégré zai de pi s’approprie automatiquement n’importe quel nom de modèle glm-* et contourne entièrement ta surcharge de base URL. Sans le flag forcé, pointer pibox sur un proxy compatible Z.AI et demander un modèle glm-4.6 ignore silencieusement ton proxy et parle directement à ce que le provider zai de pi croit être le bon. C’est le genre de bug qui coûte à quelqu’un un après-midi et un ticket de support avant que quiconque remarque que le trafic n’a jamais touché le proxy.

Le Mode Schéma: pi N’a Aucune Validation Native, Donc C’est un Ajout au Prompt Système

pi ne valide pas les schémas JSON. Il n’a aucun flag pour ça. Donc quand une requête porte un jsonSchema, le seul coup que peut jouer l’adaptateur est d’ajouter une directive au prompt système disant au modèle, en anglais tout simple, « réponds par un unique document JSON conforme à ce schéma, pas de prose, pas de barrières de code », puis de lui tendre le schéma brut en JSON avec. Toute la validation réelle, parser le résultat, le vérifier contre le schéma, réessayer jusqu’à trois fois avec un prompt correctif quand ça échoue, se passe dans la couche de base aicodebox, pas dans pibox. Le seul boulot de l’adaptateur est d’orienter le modèle vers la conformité; il n’a pas son mot à dire sur le fait que le modèle s’y conforme vraiment.

Cette séparation compte à cause de ce qui est arrivé ensuite. L’assistant de reprise sur schéma de la base est devenu progressivement plus malin sans qu’une seule ligne du code d’adaptateur de pibox ne change, et les commentaires de changelog du Dockerfile lui-même se lisent comme un journal comportemental de la base évoluant sous un contrat d’adaptateur parfaitement stable: des reprises qui reformulent la tâche d’origine au lieu de juste l’erreur (pour qu’un schéma demandant un choix dans un gros enum ne réessaie pas à l’aveugle), des workspaces éphémères par requête pour qu’une requête de 100k tokens nécessitant trois reprises paie environ 1,5k tokens de surcoût correctif au lieu de rejouer les 100k complets trois fois, et, plus récemment, stream:true combiné à l’appel d’outils ou au mode schéma qui ne renvoie plus un 400 sec. Il calcule maintenant la réponse complète sans streaming et la rejoue en un unique flux SSE tamponné: un chunk de rôle, un delta de content ou de tool_calls, un chunk de fin, [DONE]. Le chat simple streame toujours token par token. Rien de tout ça n’a touché pibox/pibox/adapter.py. C’est tout l’intérêt du contrat, l’image enfant n’a pas le droit de savoir ni d’avoir à se soucier que la base a gagné en intelligence en dessous d’elle.

L’Extension mcp-bridge: Donner à pi le Format de Config de Quelqu’un d’Autre

pi ne lit pas nativement un .mcp.json de workspace comme le fait Claude Code. pibox livre une extension TypeScript, pibox/extensions/mcp-bridge/index.ts, qui le fait pour pi: au démarrage de session elle lit le .mcp.json du workspace avec le schéma claude-code (mcpServers.<name>.{command,args,env}), lance chaque serveur en stdio, appelle listTools(), et enregistre chaque outil auprès de pi sous un nom assaini mcp__<server>__<tool> via pi.registerTool(). Les appels d’outils sont transmis au serveur MCP et le résultat revient par le même canal qu’utilisent les outils intégrés de pi, le modèle ne voit pas la différence.

Le npm install de cette extension se fait au moment du build, RUN cd /opt/pibox/extensions/mcp-bridge && npm install --omit=dev --no-audit --no-fund, pas au premier démarrage du container. C’est un choix délibéré: personne ne veut que son premier run d’agent cale sur un resolve npm. Un script init.d, pibox/init.d/10-pi-extensions.sh, branche le chemin de l’extension pré-installée dans le tableau extensions de ~/.pi/agent/settings.json au premier lancement, de façon idempotente, via un merge jq qui déduplique avec unique pour qu’une relance n’empile pas de doublons.

Il y a aussi là-dedans une course à l’arrêt qui vaut le coup d’être signalée parce que c’est le genre de bug qui ne sort qu’en production: pi -p reste bloqué après avoir imprimé sa réponse finale si les sous-processus de serveur MCP lancés tiennent encore la boucle d’événements ouverte. L’extension écoute session_shutdown, fait courser la fermeture de chaque client MCP contre un timeout de 2 secondes, puis, ceinture et bretelles, appelle de force process.exit(0) une demi-seconde plus tard parce que certaines versions de Node gardent la boucle vivante même après que close() a résolu. Sans ça, un appel d’API one-shot resterait simplement là jusqu’à ce que quelque chose d’extérieur le tue.

pibox-entrypoint: 17 Alias et une Régénération de Config au Boot

pibox-entrypoint.sh existe uniquement pour donner à pibox sa propre surface de variables d’environnement brandée sans dupliquer la moindre logique de la base. Il définit une liste de 17 suffixes, 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, et pour chacun, si PIBOX_<suffix> est posée et pas AICODEBOX_<suffix>, il recopie la valeur. AICODEBOX_* l’emporte si les deux sont posées, comme ça les utilisateurs avancés ne sont pas privés des noms sous-jacents. Les deux variables de sélection d’adaptateur, ADAPTER et AGENT_BINARY, sont délibérément exclues de cette liste; celles-là sont épinglées par le Dockerfile et ne sont pas quelque chose qu’un utilisateur devrait pouvoir surcharger à l’exécution.

L’entrypoint lance aussi setup-provider-env.sh à chaque boot, pas seulement au premier, et ce « à chaque boot » est un correctif pour un vrai bug, pas un choix de style. Le script régénère l’entrée de provider anthropic de pi dans ~/.pi/agent/models.json à partir d’ANTHROPIC_BASE_URL / ANTHROPIC_MODEL à chaque démarrage du container. Avant il passait par init.d, qui par conception ne se déclenche qu’une fois par vie de container, très bien pour un container jetable, cassé dès que quelqu’un bind-monte ~/.aicodebox ou ~/.pi en volume persistant, parce qu’alors le marqueur d’init survit à la reconstruction et un ANTHROPIC_BASE_URL modifié n’atteint plus jamais models.json, en silence. Le déplacer dans l’entrypoint veut dire que la config de base URL est rafraîchie à chaque démarrage, volume persistant ou pas.

v0.16.0: la route Anthropic n’est plus qu’une sur quatre

Tout ce qui précède est écrit autour d’une seule forme, tordre le provider anthropic de pi vers l’endpoint que tu désignes avec ANTHROPIC_BASE_URL. Ça marche toujours, et c’est toujours ce qui tourne si c’est tout ce que tu poses. Mais c’est désormais le chemin hérité.

La v0.16.0 a rendu le provider générique. Cinq variables décrivent n’importe quel endpoint HTTP: PIBOX_PROVIDER_BASE_URL, PIBOX_PROVIDER_API, PIBOX_PROVIDER_API_KEY, PIBOX_PROVIDER_MODEL, PIBOX_PROVIDER_NAME. Celle avec API choisit la forme sur le fil, et il y en a quatre: openai-completions, openai-responses, anthropic-messages, google-generative-ai. Ce qui couvre LiteLLM et Z.AI sans faire semblant que l’un ou l’autre soit Anthropic.

Le hack d’injection forcée dans build_argv a grandi en conséquence. C’est un sélecteur à deux branches maintenant: utilise le provider configuré s’il y en a un, sinon retombe sur anthropic, et injecte --model avec.

Pose PIBOX_PROVIDER_* et ANTHROPIC_BASE_URL en même temps et pibox sort au lieu d’en choisir un. Bien. Deux schémas de routage à moitié configurés qui se contredisent, c’est exactement l’échec que tu veux bruyant et au boot, pas silencieux et trois appels d’outils plus loin.

Un détail qui vaut d’être volé: la clé en amont ne vit que dans l’environnement du processus Pi. Ce qui atterrit dans models.json est une référence du genre $OPENAI_API_KEY, pas le secret lui-même, donc le fichier de config sur disque reste ennuyeux si quelqu’un le lit.

v0.18.0: il a enfin eu un wrapper

Tout ce qui est en dessous était avant la seule façon de le lancer: des lignes de docker run écrites à la main avec les montages et l’environnement épelés à chaque fois. claudebox et codexbox avaient tous les deux un wrapper hôte et un installeur. pibox non, parce que c’était l’enfant sans éclat et que personne ne s’y était mis.

Il en a un maintenant. install.sh pose une commande pibox sur ton PATH et le wrapper s’occupe de la plomberie de containers, donc le lancer dans un dossier c’est juste pibox. Dix variables le pilotent: PIBOX_DATA_DIR, PIBOX_STATE_DIR et PIBOX_SSH_DIR pour où vit l’état, PIBOX_IMAGE et PIBOX_FULL pour quelle image, PIBOX_DETACH pour les runs en arrière-plan, PIBOX_ENV_* et PIBOX_MOUNT_* pour transmettre environnement et montages, et PIBOX_INSTALL_DIR avec PIBOX_BIN_NAME pour décider où la commande atterrit et comment elle s’appelle.

La v0.17.0 lui a aussi donné une variante psyb0t/pibox:latest-full construite sur aicodebox:v0.15.0-full, là où la chaîne d’outils partagée Go, Node, Python, clients de bases de données et ops a déménagé. pibox a arrêté d’en construire la moindre part lui-même.

Et les boîtes peuvent s’appeler entre elles

Installe pibox, claudebox et codexbox dans le même dossier et chaque wrapper monte les deux autres en lecture seule sur /usr/local/bin/<name>, donc pi peut confier une tâche à un autre agent sans quitter sa session. Les runs imbriqués portent un contexte hôte versionné, AICODEBOX_LAUNCH_CONTEXT_VERSION=1 plus AICODEBOX_HOST_HOME, AICODEBOX_HOST_WORKSPACE, AICODEBOX_HOST_WRAPPER_DIR et un home et un chemin de wrapper par agent, parce qu’un chemin à l’intérieur d’un container ne dit rien de l’hôte. Un run imbriqué saute les écritures de fichiers d’auth que fait un run de premier niveau, donc un enfant ne peut pas réécrire les identifiants de son appelant. AICODEBOX_ENV_* et AICODEBOX_MOUNT_* transmettent à toutes les boîtes d’un coup, et AICODEBOX_MANAGED_INSTALL=1 est une installation non interactive qui refuse d’écraser une clé SSH existante.

Le bug qui faisait de la liste de modèles un mensonge

Ça vaut d’être signalé parce que la section ci-dessus promet quelque chose que la v0.16.0 n’a pas réellement livré. Tu pouvais lister plusieurs modèles dans PIBOX_AVAILABLE_MODELS, mais seul celui nommé dans PIBOX_PROVIDER_MODEL était enregistré auprès du provider. Demande n’importe lequel des autres et le run mourait avec Stream ended without finish_reason, ce qui ne te dit rien de la vraie cause. La v0.16.2 enregistre chaque modèle annoncé, donc la liste veut dire ce qu’elle dit. Le démarrage prévient aussi maintenant quand PIBOX_PROVIDER_BASE_URL et PIBOX_PROVIDER_API ne sont pas d’accord sur le protocole que tu parles, ce qui est l’autre manière d’obtenir un échec confus trois appels plus loin.

Encore un hérité de la base: eventMode sur un run contrôle la rétention d’événements indépendamment du fait que tu aies demandé un schéma. full te rend les enregistrements natifs du provider intacts plutôt que la version résumée.

Utilisation

En one-shot, sans serveur:

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"

Serveur d’API, le même aliasing de variables d’environnement qu’on retrouve dans chaque image 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:latest

Ou construis-la toi-même sur la base épinglée:

# derives VERSION from pibox/pyproject.toml, pulls
# psyb0t/aicodebox:v0.14.0, tags :v<VERSION> and :latest
make build

PIBOX_API_MODE=1 lève FastAPI sur :8080 et refuse de démarrer sans PIBOX_AVAILABLE_MODELS posée, parce qu’il n’y a pas de défaut sensé, pi pouvant piloter la liste de modèles de n’importe quel provider, et une liste de modèles silencieusement vide est pire qu’un échec au démarrage. Il expose /run, /openai/v1/chat/completions, /files/*, et, quand PIBOX_MCP_MODE=1, /mcp monté sur le même port. Les modes Telegram et cron reçoivent le même traitement couvert dans le post sur aicodebox; va lire celui-là si tu veux la matrice des modes plutôt que la plomberie spécifique à pi.

L’Enfant Sans Éclat

pibox n’est pas l’image enfant qui brille. C’est celle qui prouve que le contrat de la base survit au contact d’un binaire d’agent qui partait du principe qu’un humain, et pas une API, allait lire sa sortie. Deux variables d’environnement ancrent l’adaptateur, une troisième étiquette juste l’image, une classe Python traduit le CLI en forme de terminal de pi en quelque chose que la base sait piloter, et le travail de fonctionnalités propre à la base, streaming, reprises sur schéma, appel d’outils, a atterri en dessous sans qu’une seule ligne de code d’adaptateur bouge. Voilà ce que « implémentation de référence » est censé vouloir dire: pas la plus grosse, celle qui prouve que la plus petite surface marche encore.

Si tu veux l’agent plus complet, même base, Claude Code au lieu de pi, c’est le post sur claudebox. Si tu veux voir le frère de pibox à la sauce Z.AI tourner comme un vrai provider dans une stack plus grosse plutôt qu’en autonome, c’est dans le post sur aigate, pibox-zai étant l’un des providers vers lesquels aigate route. Le code est sur GitHub. C’est l’image la moins intéressante de la famille et celle que je montrerais en premier si tu écris la tienne.

L’Installer Dans Ton Agent

L’image la moins intéressante de la famille livre quand même le même chemin d’installation que les autres. Tout ce qui est sous .agents/ est catalogué dans un seul marketplace, donc ça fait deux commandes:

claude plugin marketplace add psyb0t/agents
claude plugin install pibox@psyb0t

Codex utilise le même marketplace avec un verbe différent, codex plugin add pibox@psyb0t, parce qu’il n’existe pas de codex plugin install. Il trouve aussi le skill tout seul dans un checkout du repo, puisqu’il scanne .agents/skills/ nativement sans que rien ne soit installé. Il est aussi listé sur le MCP Registry officiel maintenant, donc un client qui résout ses serveurs depuis là peut le trouver sans qu’on lui donne une URL.