pibox: Three Env Vars Is All It Took to Wire pi-coding-agent Into aicodebox

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 text and --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 --schema flag, no structured-output mode. It just talks.
  • No native ANTHROPIC_BASE_URL support. 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 zai provider auto-claims any glm-* model name, which means a request meant to go through your ANTHROPIC_BASE_URL override 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.

Three 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 three environment variables:

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

That’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. PIBOX_IMAGE_VARIANT isn’t part of that contract at all, it just labels which of the two published images you’re sitting in, minimal or full. 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 invokes pi -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-session for ephemeral, --continue otherwise.
  • validate (optional, base just checks output_format) — rejects a thinking value outside off/minimal/low/medium/high/xhigh, and rejects tools_allowlist combined with no_tools as mutually exclusive nonsense.
  • translate_auth (optional, base default is a no-op) — overridden anyway, still returns nothing, because pi reads ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / ZAI_API_KEY natively. 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, pulling session for the session id, message_end for assistant text and usage, and turn_end as a usage fallback. This is also where provider_error gets populated: when an assistant turn carries stopReason=error plus an errorMessage — pi’s way of reporting an upstream rejection, rate limit, or auth failure — the adapter captures it and forwards it on RunResult.provider_error so the OpenAI route can return a real 400 instead of a 200 with empty text.
  • parse_events (optional, base default returns an empty list) — JSON-decodes every NDJSON line for the json-verbose output 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’s message_update.assistantMessageEvent stream, forwards only text_delta to the wire, and silently swallows thinking_* and tool-use deltas so the model’s internal reasoning never leaks into the OpenAI-compatible content field.
  • auth_paths (optional, base default returns nothing to persist) — lists pi’s real state: ~/.pi/agent/auth.json, settings.json, models.json, and the sessions directory, so OAuth tokens and session history survive a docker start instead 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-provider-env.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.

v0.16.0: the Anthropic route is now just one of four

Everything above is written around one shape, bending pi’s anthropic provider at whatever endpoint you point ANTHROPIC_BASE_URL at. That still works, and it’s still what runs if that’s all you set. But it’s the legacy path now.

v0.16.0 made the provider generic. Five variables describe any HTTP endpoint: PIBOX_PROVIDER_BASE_URL, PIBOX_PROVIDER_API, PIBOX_PROVIDER_API_KEY, PIBOX_PROVIDER_MODEL, PIBOX_PROVIDER_NAME. The API one picks the wire shape, and there are four: openai-completions, openai-responses, anthropic-messages, google-generative-ai. Which covers LiteLLM and Z.AI without pretending either of them is Anthropic.

The force-injection hack in build_argv grew up accordingly. It’s a two-branch selector now: use the configured provider if there is one, otherwise fall back to anthropic, and inject --model along with it.

Set PIBOX_PROVIDER_* and ANTHROPIC_BASE_URL at the same time and pibox exits instead of picking one. Good. Two half-configured routing schemes that disagree is exactly the failure you want loud and at boot, not quiet and three tool calls deep.

One detail worth stealing: the upstream key only ever lives in the Pi process environment. What lands in models.json is a reference like $OPENAI_API_KEY, not the secret itself, so the config file on disk stays boring if somebody reads it.

v0.18.0: it finally got a wrapper

Everything below this used to be the only way to run it: hand-written docker run lines with the mounts and the env spelled out every time. claudebox and codexbox both had a host wrapper and an installer. pibox did not, because it was the unflashy child and nobody got around to it.

It has one now. install.sh puts a pibox command on your PATH and the wrapper does the container plumbing, so running it in a directory is just pibox. Ten variables steer it: PIBOX_DATA_DIR, PIBOX_STATE_DIR and PIBOX_SSH_DIR for where state lives, PIBOX_IMAGE and PIBOX_FULL for which image, PIBOX_DETACH for background runs, PIBOX_ENV_* and PIBOX_MOUNT_* to pass through environment and mounts, and PIBOX_INSTALL_DIR with PIBOX_BIN_NAME to decide where the command lands and what it is called.

v0.17.0 also gave it a psyb0t/pibox:latest-full variant built on aicodebox:v0.15.0-full, which is where the shared Go, Node, Python, database-client and ops toolchain moved. pibox stopped building any of that itself.

And the boxes can call each other

Install pibox, claudebox and codexbox into the same directory and each wrapper bind-mounts the other two read-only at /usr/local/bin/<name>, so pi can hand a task to a different agent without leaving its session. Nested runs carry a versioned host context, AICODEBOX_LAUNCH_CONTEXT_VERSION=1 plus AICODEBOX_HOST_HOME, AICODEBOX_HOST_WORKSPACE, AICODEBOX_HOST_WRAPPER_DIR and a per-agent home and wrapper path, because paths inside a container say nothing about the host. A nested run skips the auth-file writes a top-level run does, so a child cannot rewrite its caller’s credentials. AICODEBOX_ENV_* and AICODEBOX_MOUNT_* forward to every box at once, and AICODEBOX_MANAGED_INSTALL=1 is a non-interactive install that refuses to overwrite an existing SSH key.

The bug that made the model list a lie

Worth calling out because the section above promises something v0.16.0 did not actually deliver. You could list several models in PIBOX_AVAILABLE_MODELS, but only the one named in PIBOX_PROVIDER_MODEL was registered with the provider. Ask for any of the others and the run died with Stream ended without finish_reason, which tells you nothing about the real cause. v0.16.2 registers every advertised model, so the list means what it says. Startup now also warns when PIBOX_PROVIDER_BASE_URL and PIBOX_PROVIDER_API disagree about which protocol you are speaking, which is the other way to get a confusing failure three calls deep.

One more inherited from the base: eventMode on a run controls event retention independently of whether you asked for a schema. full hands back the provider’s native records untouched rather than the summarised version.

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:latest

Or 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 build

PIBOX_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, a third just labels the image, 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.

Installing It Into Your Agent

The least interesting image in the family still ships the same install path as the rest. Everything under .agents/ is catalogued in one marketplace, so it is two commands:

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

Codex uses the same marketplace with a different verb — codex plugin add pibox@psyb0t, because there is no codex plugin install. It also finds the skill on its own in a checkout of the repo, since it scans .agents/skills/ natively with nothing installed at all. It is also listed on the official MCP Registry now, so a client that resolves servers from there can find it without being handed a URL.