pibox: Drie Omgevingsvariabelen Was Alles Wat Nodig Was om pi-coding-agent aan aicodebox Te Knopen

Ik heb aicodebox gebouwd als abstractie, één base image, een AgentAdapter-contract, je prikt de CLI van welke codeeragent dan ook erin en je krijgt het API/MCP/Telegram/cron-oppervlak er gratis bij. Mooie theorie. Theorieën zijn geen reet waard tot je daadwerkelijk een tweede, volledig ander agent-binary aan het contract hebt gedraad en hebt gekeken waar het bloedt. Dus ik pakte pi-coding-agent, een CLI die ik niet geschreven heb en niet beheer, en duwde hem door de adapter. Dat is pibox. Het is geen vlaggenschipimage vol functies, het is het bewijs dat de abstractie geen leugen is, en inmiddels de referentie waar elk ander kindimage van wordt overgeschreven, die van Claude Code inbegrepen.

Als je het aicodebox-bericht al gelezen hebt ken je de pitch: modi, adapters, REST, OpenAI-compat, MCP, Telegram, cron, allemaal in de basislaag, allemaal gratis zodra je een adapter schrijft. Dat tik ik hier niet opnieuw. Dit bericht gaat over wat het werkelijk kostte om een agent-binary van een derde partij aan dat contract te schroeven zonder te valsspelen, en over hoe klein het oppervlak uiteindelijk werd toen ik stopte met shit toevoegen die niets droeg.

Wat pi Je Niet Geeft

pi-coding-agent is een prima CLI. Hij is ook, zoals elke agent-CLI op deze planeet, gebouwd voor een mens aan een terminal, niet voor een serverproces dat gestructureerde metadata terug nodig heeft. Hem in aicodebox draden betekende om elk van pi’s zeer redelijke, zeer terminalvormige aannames heen werken:

  • Twee uitvoermodi, één onbruikbaar voor een API. pi heeft --mode text en --mode json. De tekstmodus geeft je de woorden van de assistent en verder helemaal niets, geen sessie-id, geen verbruik, geen events per beurt. Prima voor een mens, nutteloos voor een route die tokens moet factureren en sessies moet hervatten.
  • Nul native JSON-schemahandhaving. pi heeft geen --schema-flag, geen modus voor gestructureerde uitvoer. Hij praat gewoon.
  • Geen native ondersteuning voor ANTHROPIC_BASE_URL. pi’s eigen documentatie zegt «gebruik models.json», oftewel hij leest de omgevingsvariabele niet waarlangs een Anthropic-compatibele proxy (Z.AI, OpenRouter, je eigen gateway) verwacht te werken.
  • Een ingebouwde provider die stilletjes je routing kaapt. pi’s zai-provider claimt automatisch elke glm-*-modelnaam, wat betekent dat een verzoek dat via jouw ANTHROPIC_BASE_URL-overschrijving hoorde te gaan stilletjes kan worden omgeleid naar een provider waar je nooit om vroeg.
  • Geen eersteklas MCP-vanuit-workspaceconfig-ondersteuning zoals Claude Code die heeft, dus geen automatisch oppikken van een .mcp.json uit de workspace.

Niets daarvan is pi’s schuld, het is een CLI, hij doet CLI-dingen. Maar «het is niet de schuld van de agent» levert je geen API op. Iemand moet vertalen. Die iemand is één Python-klasse.

Drie Omgevingsvariabelen Zijn De Hele Verankering

Hier is het deel dat pibox de titel «referentie» daadwerkelijk verdient. Strip het Dockerfile tot wat het specifiek voor pibox zet en je houdt precies drie omgevingsvariabelen over:

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

Meer niet. Dat is het hele declaratieve oppervlak dat een kindimage nodig heeft: richt AICODEBOX_ADAPTER op een importeerbare module:ClassName, vertel de basis welke binarynaam hij moet aanroepen, en de hele modusmachinerie, REST, OpenAI-compat, MCP, Telegram, cron, komt eromheen tot leven. PIBOX_IMAGE_VARIANT hoort helemaal niet bij dat contract, het labelt alleen in welk van de twee gepubliceerde images je zit, de minimale of de volle. Al het andere in het Dockerfile installeert of het agent-binary, of het Python-pakket dat de adapter implementeert, of het is branding. Als je je eigen kindimage bouwt en het Dockerfile heeft een derde omgevingsvariabele nodig om de agent te laten draaien, dan doe je iets wat het contract je nooit had willen opdringen, ga de AgentAdapter-klasse van de basis nog eens lezen.

De rest van de build is met opzet saai: npm install -g @earendil-works/[email protected], vastgepind, niet @latest, want «werkt vandaag» en «werkt over zes maanden» zijn niet dezelfde bewering, daarna uv pip install --system --break-system-packages --no-deps /opt/pibox voor het adapterpakket zelf (--no-deps omdat aicodebox al in het base image zit en het opnieuw oplossen verspild werk is).

De Adapter: build_argv Is Verplicht, Al Het Andere Is Een Keuze

De AgentAdapter-klasse van de basis heeft precies één methode die NotImplementedError gooit als je hem niet overschrijft: build_argv. Elke andere hook, validate, translate_auth, parse_output, parse_events, parse_stream_event, interactive_argv, passthrough_argv, auth_paths, komt met een werkende default. PiAdapter overschrijft ze toch allemaal, omdat pi’s CLI raar genoeg is dat de defaults rommel zouden opleveren. Dit is wat elk ervan je werkelijk oplevert, geverifieerd tegen pibox/pibox/adapter.py:

  • build_argv (verplicht): roept altijd pi -p --mode json aan, nooit --mode text, precies zodat de adapter de volledige sessie-eventstream krijgt in plaats van kale proza. De sessieafhandeling vertakt in drieën: --session <id> bij hervatten, --no-session voor vluchtig, anders --continue.
  • validate (optioneel, de basis controleert alleen output_format): weigert een thinking-waarde buiten off/minimal/low/medium/high/xhigh, en weigert tools_allowlist gecombineerd met no_tools als wederzijds uitsluitende onzin.
  • translate_auth (optioneel, de default van de basis doet niets): toch overschreven, geeft nog steeds niets terug, omdat pi ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / ZAI_API_KEY native leest. De overschrijving bestaat om dat feit in code te documenteren, niet om gedrag te wijzigen.
  • parse_output (optioneel, de default van de basis trimt stdout alleen als platte tekst): loopt door pi’s NDJSON heen en haalt session op voor de sessie-id, message_end voor assistenttekst en verbruik, en turn_end als terugval voor verbruik. Hier wordt ook provider_error gevuld: wanneer een assistentbeurt stopReason=error plus een errorMessage draagt, pi’s manier om een afwijzing stroomopwaarts, een rate limit of een auth-fout te melden, vangt de adapter dat op en stuurt het door op RunResult.provider_error zodat de OpenAI-route een echte 400 kan teruggeven in plaats van een 200 met lege tekst.
  • parse_events (optioneel, de default van de basis geeft een lege lijst terug): decodeert elke NDJSON-regel als JSON voor de uitvoermodus json-verbose; misvormde regels worden met een waarschuwing weggegooid, niet met een crash.
  • parse_stream_event (optioneel, de default van de basis behandelt elke regel als een rauw tekstdelta): decodeert pi’s message_update.assistantMessageEvent-stream, stuurt alleen text_delta door over de lijn, en slikt thinking_*– en tool-use-delta’s stilletjes in zodat het interne redeneren van het model nooit lekt naar het OpenAI-compatibele content-veld.
  • auth_paths (optioneel, de default van de basis geeft niets terug om te bewaren): somt pi’s echte staat op, ~/.pi/agent/auth.json, settings.json, models.json en de map sessions, zodat OAuth-tokens en sessiegeschiedenis een docker start overleven in plaats van te verdampen.

Er zit ook een hack in build_argv waar ik niet trots op ben maar die ik zonder voorbehoud zal verdedigen: als ANTHROPIC_BASE_URL gezet is en de aanroeper nog geen provider heeft gekozen in extra_args, injecteert de adapter geforceerd --provider anthropic. Waarom? Omdat pi’s ingebouwde zai-provider automatisch elke glm-*-modelnaam claimt en je base-URL-overschrijving volledig omzeilt. Zonder die geforceerde flag negeert pibox, gericht op een Z.AI-compatibele proxy en gevraagd om een glm-4.6-model, stilletjes je proxy en praat rechtstreeks met wat pi’s zai-provider denkt dat het moet zijn. Dat is het soort bug dat iemand een middag en een supportticket kost voordat iemand doorheeft dat het verkeer de proxy nooit heeft aangeraakt.

Schemamodus: pi Heeft Geen Native Handhaving, Dus Het Is Een Aanbouw Aan De Systeemprompt

pi valideert geen JSON-schema’s. Het heeft er geen flag voor. Dus wanneer een verzoek een jsonSchema draagt, is de enige zet van de adapter een richtlijn aan de systeemprompt plakken die het model in simpel Engels vertelt «antwoord met één JSON-document dat aan dit schema voldoet, geen proza, geen codehekken», en dan het rauwe schema-JSON erbij aanreiken. Alle daadwerkelijke handhaving, het resultaat parsen, het tegen het schema controleren, tot drie keer opnieuw proberen met een corrigerende prompt als het faalt, gebeurt in de aicodebox-basislaag, niet in pibox. De enige taak van de adapter is het model richting naleving duwen; hij heeft niets te zeggen over de vraag of het model daadwerkelijk naleeft.

Die scheiding doet ertoe vanwege wat er later kwam. De schema-retryhelper van de basis werd gaandeweg slimmer zonder dat er ook maar één regel adaptercode van pibox veranderde, en de changelog-commentaren in het Dockerfile zelf lezen als een gedragsdagboek van de basis die evolueert onder een volledig stabiel adaptercontract: retries die de oorspronkelijke taak herformuleren in plaats van alleen de fout (zodat een schema dat een keuze uit een grote enum vraagt niet blind opnieuw probeert), vluchtige workspaces per verzoek zodat een verzoek van 100k tokens dat drie retries nodig heeft ruwweg 1,5k tokens aan corrigerende overhead betaalt in plaats van de volle 100k drie keer opnieuw af te spelen, en, het meest recent, stream:true gecombineerd met tool calling of schemamodus die geen kale 400 meer teruggeeft. Het berekent nu het volledige antwoord zonder streaming en speelt het af als één gebufferde SSE-stream: één rolchunk, één content- of tool_calls-delta, een afsluitchunk, [DONE]. Gewone chat streamt nog steeds token voor token. Niets daarvan raakte pibox/pibox/adapter.py. Dat is het hele punt van het contract, het kindimage mag niet weten of zich erom bekommeren dat de basis eronder slimmer werd.

De mcp-bridge-extensie: pi het configformaat van iemand anders geven

pi leest niet native een workspace-.mcp.json zoals Claude Code dat doet. pibox levert een TypeScript-extensie mee, pibox/extensions/mcp-bridge/index.ts, die dat voor pi doet: bij het starten van een sessie leest hij de .mcp.json uit de workspace volgens het claude-code-schema (mcpServers.<name>.{command,args,env}), start elke server over stdio, roept listTools() aan, en registreert elke tool bij pi onder een opgeschoonde naam mcp__<server>__<tool> via pi.registerTool(). Toolaanroepen worden doorgestuurd naar de MCP-server en het resultaat komt terug via hetzelfde kanaal dat pi’s ingebouwde tools gebruiken, het model merkt het verschil niet.

De npm install voor die extensie gebeurt op buildmoment, RUN cd /opt/pibox/extensions/mcp-bridge && npm install --omit=dev --no-audit --no-fund, niet bij de eerste containerstart. Dat is een bewuste keuze: niemand wil dat zijn eerste agentrun blijft hangen op een npm-resolve. Een init.d-script, pibox/init.d/10-pi-extensions.sh, draadt het pad van de voorgeïnstalleerde extensie bij de eerste start in de extensions-array van ~/.pi/agent/settings.json, idempotent, via een jq-merge die dedupliceert met unique zodat opnieuw draaien geen dubbele vermeldingen opstapelt.

Er zit daar ook een afsluitrace in die het aanwijzen waard is, want het is het soort bug dat alleen in productie opduikt: pi -p blijft hangen nadat hij zijn eindantwoord heeft geprint als de gestarte MCP-serversubprocessen de event loop nog openhouden. De extensie luistert naar session_shutdown, laat het sluiten van elke MCP-client racen tegen een timeout van 2 seconden, en roept dan, riem en bretels, een halve seconde later geforceerd process.exit(0) aan omdat sommige Node-versies de loop levend houden zelfs nadat close() is opgelost. Zonder dat zou een eenmalige API-aanroep daar gewoon blijven zitten tot iets van buitenaf hem doodde.

pibox-entrypoint: 17 Aliassen en een Configregeneratie bij het Booten

pibox-entrypoint.sh bestaat puur om pibox zijn eigen gebrande omgevingsvariabelenoppervlak te geven zonder ook maar enige logica van de basis te dupliceren. Het definieert een lijst van 17 achtervoegsels, 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, en voor elk daarvan kopieert het de waarde over als PIBOX_<suffix> gezet is en AICODEBOX_<suffix> niet. AICODEBOX_* wint als beide gezet zijn, zodat gevorderde gebruikers niet buitengesloten raken van de onderliggende namen. De twee adapterselectievariabelen, ADAPTER en AGENT_BINARY, zijn bewust van die lijst uitgesloten; die worden door het Dockerfile vastgepind en zijn niet iets wat een gebruiker tijdens runtime zou moeten kunnen overschrijven.

De entrypoint draait ook setup-provider-env.sh bij elke boot, niet alleen de eerste, en dat «elke boot» is een reparatie van een echte bug, geen stijlkeuze. Het script regenereert pi’s anthropic-providervermelding in ~/.pi/agent/models.json uit ANTHROPIC_BASE_URL / ANTHROPIC_MODEL elke keer dat de container start. Vroeger liep het via init.d, dat per ontwerp maar één keer per containerleven afgaat, prima voor een wegwerpcontainer, kapot op het moment dat iemand ~/.aicodebox of ~/.pi als blijvend volume bind-mount, want dan overleeft de init-marker de herbouw en bereikt een gewijzigde ANTHROPIC_BASE_URL stilletjes nooit meer models.json. Het naar de entrypoint verplaatsen betekent dat de base-URL-config bij elke start wordt ververst, blijvend volume of niet.

v0.16.0: de Anthropic-route is nu nog maar één van vier

Alles hierboven is geschreven rond één vorm, pi’s anthropic-provider buigen naar het endpoint waar je ANTHROPIC_BASE_URL op richt. Dat werkt nog steeds, en dat draait ook nog steeds als dat alles is wat je zet. Maar het is nu het erfenispad.

v0.16.0 maakte de provider generiek. Vijf variabelen beschrijven elk willekeurig HTTP-endpoint: PIBOX_PROVIDER_BASE_URL, PIBOX_PROVIDER_API, PIBOX_PROVIDER_API_KEY, PIBOX_PROVIDER_MODEL, PIBOX_PROVIDER_NAME. Die met API kiest de vorm op de lijn, en daar zijn er vier van: openai-completions, openai-responses, anthropic-messages, google-generative-ai. Wat LiteLLM en Z.AI dekt zonder te doen alsof een van beide Anthropic is.

De geforceerde-injectiehack in build_argv groeide navenant mee. Het is nu een selector met twee takken: gebruik de geconfigureerde provider als die er is, val anders terug op anthropic, en injecteer --model erbij.

Zet PIBOX_PROVIDER_* en ANTHROPIC_BASE_URL tegelijk en pibox stopt ermee in plaats van er een te kiezen. Goed. Twee halfgeconfigureerde routingschema’s die elkaar tegenspreken is precies het soort falen dat je luid en bij het opstarten wil, niet stil en drie toolaanroepen diep.

Eén detail dat het stelen waard is: de stroomopwaartse sleutel leeft uitsluitend in de omgeving van het Pi-proces. Wat er in models.json belandt is een verwijzing als $OPENAI_API_KEY, niet het geheim zelf, dus het configbestand op schijf blijft saai als iemand het leest.

v0.18.0: het heeft eindelijk een wrapper gekregen

Alles hieronder was vroeger de enige manier om het te draaien: met de hand geschreven docker run-regels met de mounts en de omgeving er elke keer voluit bij. claudebox en codexbox hadden allebei een hostwrapper en een installatieprogramma. pibox niet, want het was het onopvallende kind en niemand kwam eraan toe.

Nu heeft het er een. install.sh zet een pibox-commando op je PATH en de wrapper regelt het containerloodgieterswerk, dus het in een map draaien is gewoon pibox. Tien variabelen sturen hem: PIBOX_DATA_DIR, PIBOX_STATE_DIR en PIBOX_SSH_DIR voor waar de staat woont, PIBOX_IMAGE en PIBOX_FULL voor welk image, PIBOX_DETACH voor runs op de achtergrond, PIBOX_ENV_* en PIBOX_MOUNT_* om omgeving en mounts door te geven, en PIBOX_INSTALL_DIR met PIBOX_BIN_NAME om te bepalen waar het commando landt en hoe het heet.

v0.17.0 gaf het ook een psyb0t/pibox:latest-full-variant gebouwd op aicodebox:v0.15.0-full, waarheen de gedeelde toolchain van Go, Node, Python, databaseclients en ops is verhuisd. pibox stopte met daar zelf ook maar iets van te bouwen.

En de dozen kunnen elkaar aanroepen

Installeer pibox, claudebox en codexbox in dezelfde map en elke wrapper mount de andere twee alleen-lezen op /usr/local/bin/<name>, dus pi kan een taak aan een andere agent doorgeven zonder zijn sessie te verlaten. Geneste runs dragen een geversioneerde hostcontext, AICODEBOX_LAUNCH_CONTEXT_VERSION=1 plus AICODEBOX_HOST_HOME, AICODEBOX_HOST_WORKSPACE, AICODEBOX_HOST_WRAPPER_DIR en per agent een home en een wrapperpad, want een pad binnen een container zegt niets over de host. Een geneste run slaat de auth-bestandsschrijfacties over die een run op het hoogste niveau wel doet, dus een kind kan de credentials van zijn aanroeper niet herschrijven. AICODEBOX_ENV_* en AICODEBOX_MOUNT_* sturen in één keer naar elke doos door, en AICODEBOX_MANAGED_INSTALL=1 is een niet-interactieve installatie die weigert een bestaande SSH-sleutel te overschrijven.

De bug die de modellenlijst tot een leugen maakte

Het aanwijzen waard omdat het deel hierboven iets belooft wat v0.16.0 niet daadwerkelijk leverde. Je kon meerdere modellen opsommen in PIBOX_AVAILABLE_MODELS, maar alleen het model dat in PIBOX_PROVIDER_MODEL stond werd bij de provider geregistreerd. Vraag om een van de andere en de run stierf met Stream ended without finish_reason, wat je niets vertelt over de echte oorzaak. v0.16.2 registreert elk geadverteerd model, dus de lijst betekent wat hij zegt. Bij het opstarten wordt nu ook gewaarschuwd wanneer PIBOX_PROVIDER_BASE_URL en PIBOX_PROVIDER_API het oneens zijn over welk protocol je spreekt, wat de andere manier is om drie aanroepen diep een verwarrend falen te krijgen.

Nog een van de basis geërfd: eventMode op een run bepaalt het bewaren van events los van de vraag of je om een schema vroeg. full geeft je de native records van de provider onaangeroerd terug in plaats van de samengevatte versie.

Gebruik

Eenmalig, zonder server:

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"

API-server, hetzelfde aliassen van omgevingsvariabelen dat in elk psyb0t-image opduikt:

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

Of bouw het zelf boven op de vastgepinde basis:

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

PIBOX_API_MODE=1 trekt FastAPI op :8080 op en weigert te starten zonder een gezette PIBOX_AVAILABLE_MODELS, want er is geen zinnige default, aangezien pi de modellenlijst van welke provider dan ook kan aansturen, en een stilzwijgend lege modellenlijst is erger dan een startfout. Het stelt /run, /openai/v1/chat/completions, /files/* beschikbaar, en, wanneer PIBOX_MCP_MODE=1, /mcp gemount op dezelfde poort. De Telegram- en cronmodi krijgen dezelfde behandeling die in het aicodebox-bericht is behandeld; lees dat als je de modusmatrix wil in plaats van het pi-specifieke loodgieterswerk.

Het Onopvallende Kind

pibox is niet het opvallende kindimage. Het is het image dat bewijst dat het contract van de basis het contact overleeft met een agent-binary dat ervan uitging dat een mens, en niet een API, zijn uitvoer zou lezen. Twee omgevingsvariabelen verankeren de adapter, een derde labelt alleen het image, één Python-klasse vertaalt pi’s terminalvormige CLI naar iets wat de basis kan aansturen, en het eigen functiewerk van de basis, streaming, schema-retries, tool calling, landde eronder zonder dat er één regel adaptercode bewoog. Dat is wat «referentie-implementatie» hoort te betekenen: niet de grootste, maar die welke bewijst dat het kleinste oppervlak nog steeds werkt.

Wil je de vollere agent, dezelfde basis, Claude Code in plaats van pi, dan is dat het claudebox-bericht. Wil je pibox’ Z.AI-gekruide broer als echte provider in een grotere stack zien draaien in plaats van op zichzelf, dan staat dat in het aigate-bericht, waarbij pibox-zai een van de providers is waarnaar aigate routeert. De code staat op GitHub. Het is het minst interessante image van de familie en het eerste waar ik naar zou wijzen als je je eigen schrijft.

Hoe Je Het In Je Agent Installeert

Het minst interessante image van de familie levert toch hetzelfde installatiepad als de rest. Alles onder .agents/ staat gecatalogiseerd in één marketplace, dus het zijn twee commando’s:

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

Codex gebruikt dezelfde marketplace met een ander werkwoord, codex plugin add pibox@psyb0t, want codex plugin install bestaat niet. Het vindt de skill ook zelf in een checkout van de repo, aangezien het .agents/skills/ native scant zonder dat er ook maar iets geïnstalleerd is. Het staat inmiddels ook in de officiële MCP Registry, dus een client die servers daarvandaan oplost kan het vinden zonder dat je hem een URL aanreikt.