I built aicodebox as an abstraction — one base image, an AgentAdapter contract, plug any coding agent’s CLI into it and get the API/MCP/Telegram/cron surface for free. That’s a nice theory. Theories are worth jack shit until you’ve actually wired a second, completely different agent binary into the contract and watched where it bleeds. So I grabbed pi-coding-agent, a CLI I didn’t write and don’t control, and forced it through the adapter. That’s pibox. It’s not a feature-rich flagship image — it’s the proof that the abstraction isn’t a lie, and now it’s the reference every other child image gets copied from, including the Claude Code one.
If you already read the aicodebox post you know the pitch: modes, adapters, REST, OpenAI compat, MCP, Telegram, cron — all base-layer, all free once you write an adapter. I’m not re-typing that here. This post is about what it actually took to bolt a third-party agent binary onto that contract without cheating, and about how small the surface ended up being once I stopped adding shit that wasn’t load-bearing.
What pi Doesn’t Hand You
pi-coding-agent is a fine CLI. It is also, like every agent CLI on the planet, built for a human sitting at a terminal, not for a server process that needs structured metadata back. Wiring it into aicodebox meant working around every one of pi’s very reasonable, very terminal-shaped assumptions:
- Two output modes, one useless for an API. pi has
--mode textand--mode json. Text mode gives you the assistant’s words and absolutely nothing else — no session id, no usage, no per-turn events. Fine for a human, useless for a route that needs to bill tokens and resume sessions. - Zero native JSON-schema enforcement. pi has no
--schemaflag, no structured-output mode. It just talks. - No native
ANTHROPIC_BASE_URLsupport. pi’s own docs say “use models.json” — it will not read the env var an Anthropic-compatible proxy (Z.AI, OpenRouter, your own gateway) expects to work through. - A built-in provider that silently hijacks your routing. pi’s
zaiprovider auto-claims anyglm-*model name, which means a request meant to go through yourANTHROPIC_BASE_URLoverride can get quietly rerouted to a provider you didn’t ask for. - No first-class MCP-from-workspace-config support the way Claude Code has it — no automatic pickup of a workspace
.mcp.json.
None of that is pi’s fault — it’s a CLI, it does CLI things. But “it’s not the agent’s fault” doesn’t get you an API. Somebody has to translate. That somebody is one Python class.
Two Env Vars Is the Whole Pin
Here’s the part that actually earns pibox the “reference” title. Strip the Dockerfile down to what it sets that’s specific to pibox and you get exactly two environment variables:
ENV AICODEBOX_ADAPTER=pibox.adapter:PiAdapter
AICODEBOX_AGENT_BINARY=piThat’s it. That’s the entire declarative surface a child image needs: point AICODEBOX_ADAPTER at an importable module:ClassName, tell the base which binary name to shell out to, and the whole mode machinery — REST, OpenAI-compat, MCP, Telegram, cron — comes alive around it. Everything else in the Dockerfile is either installing the agent binary, installing the Python package that implements the adapter, or branding. If you’re building your own child image and the Dockerfile needs a third env var to make the agent run, you’re doing something the contract wasn’t supposed to make you do — go read the base’s AgentAdapter class again.
The rest of the build is boring on purpose: npm install -g @earendil-works/[email protected] — pinned, not @latest, because “works today” and “works in six months” are not the same claim — then uv pip install --system --break-system-packages --no-deps /opt/pibox for the adapter package itself (--no-deps because aicodebox is already in the base image and re-resolving it is wasted work).
The Adapter: build_argv Is Mandatory, Everything Else Is a Choice
The base’s AgentAdapter class has exactly one method that raises NotImplementedError if you don’t override it: build_argv. Every other hook — validate, translate_auth, parse_output, parse_events, parse_stream_event, interactive_argv, passthrough_argv, auth_paths — ships a working default. PiAdapter overrides all of them anyway, because pi’s CLI is weird enough that the defaults would produce garbage. Here’s what each one is actually buying you, verified against pibox/pibox/adapter.py:
build_argv(required) — always invokespi -p --mode json, never--mode text, specifically so the adapter gets the full session-event stream instead of bare prose. Session handling branches three ways:--session <id>on resume,--no-sessionfor ephemeral,--continueotherwise.validate(optional, base just checksoutput_format) — rejects athinkingvalue outsideoff/minimal/low/medium/high/xhigh, and rejectstools_allowlistcombined withno_toolsas mutually exclusive nonsense.translate_auth(optional, base default is a no-op) — overridden anyway, still returns nothing, because pi readsANTHROPIC_API_KEY/OPENAI_API_KEY/OPENROUTER_API_KEY/GEMINI_API_KEY/ZAI_API_KEYnatively. The override exists to document that fact in code, not to change behavior.parse_output(optional, base default just strips stdout as plain text) — walks pi’s NDJSON, pullingsessionfor the session id,message_endfor assistant text and usage, andturn_endas a usage fallback. This is also whereprovider_errorgets populated: when an assistant turn carriesstopReason=errorplus anerrorMessage— pi’s way of reporting an upstream rejection, rate limit, or auth failure — the adapter captures it and forwards it onRunResult.provider_errorso the OpenAI route can return a real400instead of a200with empty text.parse_events(optional, base default returns an empty list) — JSON-decodes every NDJSON line for thejson-verboseoutput mode; malformed lines get dropped with a warning, not a crash.parse_stream_event(optional, base default treats every line as a raw text delta) — decodes pi’smessage_update.assistantMessageEventstream, forwards onlytext_deltato the wire, and silently swallowsthinking_*and tool-use deltas so the model’s internal reasoning never leaks into the OpenAI-compatiblecontentfield.auth_paths(optional, base default returns nothing to persist) — lists pi’s real state:~/.pi/agent/auth.json,settings.json,models.json, and thesessionsdirectory, so OAuth tokens and session history survive adocker startinstead of evaporating.
There’s also a hack tucked into build_argv that I’m not proud of but will absolutely defend: if ANTHROPIC_BASE_URL is set and the caller hasn’t already picked a provider in extra_args, the adapter force-injects --provider anthropic. Why? Because pi’s built-in zai provider auto-claims any glm-* model name and routes around your base-URL override entirely. Without the forced flag, pointing pibox at a Z.AI-compatible proxy and asking for a glm-4.6 model silently ignores your proxy and talks straight to whatever pi’s zai provider thinks it should. That’s the kind of bug that costs someone an afternoon and a support ticket before anyone notices the traffic never touched the proxy.
Schema Mode: pi Has No Native Enforcement, So It’s a System Prompt Bolt-On
pi doesn’t validate JSON schemas. It has no flag for it. So when a request carries jsonSchema, the adapter’s only move is to append a directive to the system prompt telling the model, in plain English, “respond with a single JSON document conforming to this schema, no prose, no fences” — then hand the raw schema JSON along with it. All the actual enforcement — parsing the result, checking it against the schema, retrying up to three times with a corrective prompt when it fails — happens at the aicodebox base layer, not in pibox. The adapter’s only job is steering the model toward compliance; it has zero say in whether the model actually complies.
That split matters because of what shipped later. The base’s schema-retry helper got progressively smarter without pibox’s adapter code changing at all — the Dockerfile’s own changelog comments read like a behavioral diary of the base evolving underneath a completely stable adapter contract: retries that re-state the original task instead of just the error (so a schema needing a big enum pick doesn’t retry blind), ephemeral per-request workspaces so a 100k-token request needing three retries pays roughly 1.5k tokens of corrective overhead instead of replaying the full 100k three times over, and — most recently — stream:true combined with tool calling or schema mode no longer returning a flat 400. It now computes the full answer non-streamed and replays it as a single buffered SSE stream: one role chunk, one content or tool_calls delta, a finish chunk, [DONE]. Plain chat still streams token-by-token. None of that touched pibox/pibox/adapter.py. That’s the entire point of the contract — the child image doesn’t get to know or care that the base got smarter underneath it.
The mcp-bridge Extension: Giving pi Someone Else’s Config Format
pi doesn’t natively read a workspace .mcp.json the way Claude Code does. pibox ships a TypeScript extension, pibox/extensions/mcp-bridge/index.ts, that does it for pi: on session start it reads .mcp.json from the workspace using the claude-code schema (mcpServers.<name>.{command,args,env}), spawns each server over stdio, calls listTools(), and registers every tool with pi under a sanitized mcp__<server>__<tool> name via pi.registerTool(). Tool calls get forwarded to the MCP server and the result comes back through the same channel pi’s built-in tools use — the model can’t tell the difference.
The npm install for that extension happens at build time — RUN cd /opt/pibox/extensions/mcp-bridge && npm install --omit=dev --no-audit --no-fund — not on first container boot. That’s a deliberate choice: nobody wants their first agent run stalling on an npm resolve. An init.d script, pibox/init.d/10-pi-extensions.sh, wires the pre-installed extension path into ~/.pi/agent/settings.json‘s extensions array on first run — idempotently, via a jq merge that dedupes with unique so re-running it doesn’t pile up duplicate entries.
There’s also a shutdown race in there worth pointing out because it’s the kind of bug that only shows up in production: pi -p hangs after printing its final answer if the spawned MCP server subprocesses are still holding the event loop open. The extension listens for session_shutdown, races closing every MCP client against a 2-second timeout, and then — belt and suspenders — force-calls process.exit(0) half a second later because some Node versions keep the loop alive even after close() resolves. Without that, a one-shot API call would just sit there until something external killed it.
pibox-entrypoint: 17 Aliases and a Boot-Time Config Regen
pibox-entrypoint.sh exists purely to give pibox its own branded env-var surface without duplicating any base logic. It defines a list of 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 — and for each one, if PIBOX_<suffix> is set and AICODEBOX_<suffix> isn’t, it copies the value across. AICODEBOX_* wins if both are set, so power users aren’t locked out of the underlying names. The two adapter-selection vars — ADAPTER and AGENT_BINARY — are deliberately excluded from that list; those are pinned by the Dockerfile and not something a user should be able to override at runtime.
The entrypoint also runs setup-anthropic-baseurl.sh on every single boot, not just the first one — and that “every boot” is a fix for a real bug, not a style choice. The script regenerates pi’s anthropic provider entry in ~/.pi/agent/models.json from ANTHROPIC_BASE_URL / ANTHROPIC_MODEL every time the container starts. It used to run via init.d, which only fires once per container lifetime by design — fine for a throwaway container, broken the moment someone bind-mounts ~/.aicodebox or ~/.pi as a persisted volume, because then the init marker survives the rebuild and a changed ANTHROPIC_BASE_URL silently never reaches models.json again. Moving it to the entrypoint means the base URL config gets refreshed on every start, persisted volume or not.
Usage
One-shot, no 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, same env-var aliasing that shows up in every psyb0t image:
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:latestOr build it yourself off the pinned base:
# derives VERSION from pibox/pyproject.toml, pulls
# psyb0t/aicodebox:v0.14.0, tags :v<VERSION> and :latest
make buildPIBOX_API_MODE=1 spins up FastAPI on :8080 and refuses to boot without PIBOX_AVAILABLE_MODELS set — there’s no sensible default because pi can drive any provider’s model list, and a silently-empty model list is worse than a boot failure. It exposes /run, /openai/v1/chat/completions, /files/*, and — when PIBOX_MCP_MODE=1 — /mcp mounted on the same port. Telegram and cron modes get the same treatment covered in the aicodebox post; go read that one if you want the mode matrix instead of the pi-specific plumbing.
The Unflashy Child
pibox isn’t the flashy child image. It’s the one that proves the base’s contract survives contact with an agent binary that assumed a human, not an API, was going to be reading its output. Two env vars pin the adapter, one Python class translates pi’s terminal-shaped CLI into something the base can drive, and the base’s own feature work — streaming, schema retries, tool calling — landed underneath it without a single line of adapter code moving. That’s what “reference implementation” is supposed to mean: not the biggest, the one that proves the smallest surface still works.
If you want the fuller agent — same base, Claude Code instead of pi — that’s the claudebox post. If you want to see pibox’s Z.AI-flavored sibling running as an actual provider inside a bigger stack instead of standalone, that’s in the aigate post — pibox-zai is one of the providers aigate routes to. Code’s on GitHub. It’s the least interesting image in the family and the one I’d point at first if you’re writing your own.