aicodebox: One Base Image, Any AI Coding Agent, Four Modes You Didn’t Have To Write

I built claudebox because letting Claude Code run loose on my host was giving me chest pains. It worked. It grew an HTTP API, an OpenAI-compatible endpoint, an MCP server, a Telegram bot, and a cron scheduler. Months of work, all of it useful.
Then pi-coding-agent showed up and I wanted the exact same thing for it. Then OpenAI shipped the Codex CLI and I wanted it a third time.
And that’s where I sat down and actually looked at what I’d be doing: copy-pasting a FastAPI server, a Telegram bot with per-chat workspaces, a cron scheduler with history dirs, and an MCP server into a second repo. Then a third. Three copies of the same nine hundred lines, drifting apart the moment I fixed a bug in one and forgot the others. Three places to patch when Telegram changed a rendering rule. Three test suites testing identical logic against different agents.
Fuck that. None of that plumbing is Claude-specific. None of it is pi-specific. It’s all just “take a prompt, shell out to some CLI, catch what comes back, hand it to whoever asked.” The agent is a detail. The surfaces are the product.
So I ripped them out into a base image. That’s aicodebox.

The Problem With Every “Put An Agent On The Network” Project

Every one of these tools makes the same mistake, mine included, the first time around: it welds the transport to the brain.
You want your agent reachable over HTTP, so you write an HTTP server that knows how to invoke that specific binary, parse that specific output format, and handle that specific set of flags. Six months later there’s a better agent, and you own a pile of infrastructure that only speaks to the old one.
The symptoms are always the same:

  • Fork-and-butcher upgrades. Want the same surfaces for a different agent? Fork the repo, find every place the old binary’s name is hardcoded, hope you got them all.
  • Divergent bug fixes. The Telegram markdown renderer has a bug. You fix it in one repo. The other two keep the bug for a month until you remember.
  • Inconsistent surfaces. One box has async runs, another doesn’t. One has an OpenAI endpoint, another has half of one. Nothing composes because nothing agrees on a wire shape.
  • Agent lock-in by accident. Not because anybody decided to lock in — because the plumbing grew around one binary’s quirks and calcified there.

The fix isn’t a better wrapper. It’s admitting the wrapper shouldn’t know what it’s wrapping.

How This Shit Actually Works

aicodebox is a Docker base image. You don’t fork it. You FROM it.

FROM psyb0t/aicodebox
RUN npm install -g @earendil-works/[email protected]
COPY mypkg /opt/mypkg
RUN pip3 install --break-system-packages /opt/mypkg
ENV AICODEBOX_ADAPTER=mypkg.adapter:MyAdapter \
    AICODEBOX_AGENT_BINARY=pi

That’s the whole integration. The npm install line is incidental — that’s just how pi-coding-agent happens to ship. It could as easily be an apt-get install, a binary copied out of a Go build stage, or anything else that puts an executable on PATH; see how the agent gets installed further down. The base owns every network surface. Your adapter translates “run this prompt” into whatever your agent’s CLI expects. A new agent lands in an afternoon, and it lands with an HTTP API, an OpenAI-compatible endpoint, an MCP server, a Telegram bot, and a cron scheduler already attached — because those aren’t yours to write anymore.
What’s actually in the image:

  • OS — Ubuntu 24.04, an aicode user at UID 1000 with passwordless sudo and docker group membership.
  • Runtimes — Node.js 22 LTS (most agents ship as npm packages), Python 3.12, and Docker CE with buildx and compose, for when your agent decides it needs to spawn containers of its own.
  • The packageaicodebox: the adapter contract plus four mode dispatchers. Pure Python, zero side effects until you actually boot a mode.
  • State — per-chat overrides and cron history live under $HOME/.aicodebox/. Bind-mount it if you want it to outlive the container. The package itself stores nothing.

The Adapter Contract Is Two Methods

This is the part I’m actually proud of, because it’s small. Everything routes through one interface, and the mandatory surface of it is two methods.

from aicodebox.adapters.base import AgentAdapter, RunRequest, RunResult, StreamEvent
class MyAdapter(AgentAdapter):
    name = "my-agent"
    available_models = ["fast", "smart"]
    available_thinking_levels = ["off", "low", "high"]
    def build_argv(self, req: RunRequest) -> list[str]:
        argv = ["my-agent", "-p", req.prompt]
        if req.model: argv += ["--model", req.model]
        if req.workspace: argv += ["--cwd", req.workspace]
        return argv
    def parse_output(self, stdout: str, req: RunRequest) -> RunResult:
        return RunResult(text=stdout.strip(), raw_stdout="", raw_stderr="", exit_code=0)

build_argv says how to invoke the thing. parse_output says how to read what came back. That’s the contract. Everything else — validate, build_env, translate_auth, post_validate_json, parse_events, parse_stream_event — is optional, with defaults that do the boring correct thing.
parse_stream_event is worth calling out: the default is “one delta per stdout line,” which is fine for a binary that just prints prose. Override it only if your agent emits a structured JSON event stream and you want per-token or per-tool granularity in OpenAI streaming. Most adapters won’t bother.
The adapter gets resolved on first call and cached for the process lifetime. Every mode pulls the same one — so whatever build_argv knows how to drive is exactly what gets exposed over HTTP, MCP, Telegram, and cron. No per-mode integration work. Write it once, get five ways in.

Four Modes, Set A Flag

Modes are env vars. Set the flag, the entrypoint boots that mode. No flag, no mode — the container just runs your agent interactively like a normal shell.
The foreground modes (API, Telegram, Cron) are mutually exclusive, with one deliberate exception: Telegram and Cron share a process, because cron runs in-thread inside the Telegram bot. API wins if you set it alongside something else. MCP is independent and coexists with any of them.

API mode

AICODEBOX_API_MODE=1 boots FastAPI on :8080.

POST   /run                  # sync run
POST   /run  {"async": true} # returns {runId, status} immediately
GET    /run/result?runId=X   # poll an async run
DELETE /run/{id}             # kill an in-flight run
GET|PUT|DELETE /files/{path} # workspace file CRUD
POST   /v1/chat/completions  # OpenAI-compatible
GET    /v1/models            # model list from the adapter
POST   /mcp                  # MCP, when AICODEBOX_MCP_MODE=1

One flag drives the response shape. POST /run with no jsonSchema gives you the lean thing: {runId, workspace, exitCode, text}. Just the prose. Pass a jsonSchema and you get the full diagnostic surface — text, json, events, sessionId, usage, attempts. Under the hood the agent gets invoked in json-verbose mode, its output decoded and validated against your schema.
There’s no verbose dial. Schema means full surface, no schema means lean. Two wire shapes, one flag, no matrix of options to reason about.
Retries are honest about what they cost. On a parse or validation failure the wrapper re-prompts up to three times (JSON_RETRY_MAX = 3) with the previous bad output and the specific error. If all three fail, parseError and jsonRetries replace json — but text, events, sessionId, usage and attempts all still come back, because a failed structured run is exactly when you need the diagnostics.
And usage is the sum across every attempt, not the last one. Your provider bills you per attempt; reporting only the final attempt would be a lie. attempts carries the per-attempt breakdown so you can render “retry 2 of 3 cost this much” or bill it through.
The retry trick I like most. Schema requests that don’t name a workspace get a per-request ephemeral one under /tmp/aicodebox/<uuid>/, cleaned up in a finally. Because the session persists, retries send a minimal corrective prompt — the error, a directive, the schema — instead of replaying your entire original input. On a large prompt that’s roughly a hundredfold cut in retry input cost. If you do pass your own workspace it falls back to fresh-session retries that restate the task: safe in any workspace, just more expensive.

The OpenAI-compatible endpoint

POST /v1/chat/completions, streaming and non-streaming. Point LiteLLM, Open WebUI, or any OpenAI SDK at it and your coding agent shows up as a model.
Structured output goes through the standard field — response_format with {"type":"json_object"} for permissive or {"type":"json_schema", ...} for real structured outputs. There’s an x-aicodebox-json-schema header as a fallback for clients that can’t set the body field; the body wins if both are present. Retries exhausted returns 422. Agent process crash returns 500 with the exit code and stderr in detail.
Client-executed tool calling. Send a standard tools array and the box behaves like a plain function-calling model: it answers with tool_calls and finish_reason: "tool_calls", your client runs the tool and posts the role: "tool" result back, loop continues. Stateless, resend the full history each round, exactly like OpenAI. tool_choice supports auto / none / required / a named function.
And tools composes with response_format. A tool-call turn returns tool calls and is deliberately not schema-checked; the model’s final answer turn is validated against your schema with retries and comes back as canonical JSON. So an agentic multi-tool flow can still terminate in a structured reply, which is the thing you actually want when you’re wiring this into a pipeline.
One honest caveat: streaming with tools or response_format is buffered SSE. The full answer gets computed, then replayed as a single-shot event stream — opening role chunk, one delta, finish chunk, [DONE]. It’s a valid stream, it’s just not token-incremental. Plain chat still streams properly.

Telegram mode

AICODEBOX_TELEGRAM_MODE=1 plus a bot token. Text goes in, an agent run happens, the response comes back chunked and rendered from Markdown into Telegram’s HTML flavor. Uploads — documents, photos, video, voice — land in that chat’s workspace. The agent pushes files back by emitting [SEND_FILE: relative/path] in its output.
Per-chat overrides for /model, /effort, /system_prompt and /append_system_prompt, persisted to disk. /cancel kills the in-flight run, /reload re-reads the yaml, /config dumps the merged config, /fetch pulls a workspace file, /status lists busy chats.
The detail that makes it usable day to day: replying to a message that cron fired injects that job’s instruction and result into the context, so your follow-up question actually makes sense to the agent instead of arriving with no idea what you’re talking about.

Cron mode

Six-field croniter schedules, per-job workspace, optional Telegram notification.

jobs:
  - name: morning-report
    schedule: "0 0 9 * * *"
    instruction: |
      Summarize yesterday's git activity in {workspace}.
    workspace: shared
    telegram_chat_id: -100123
    model: claude-sonnet

Every run gets its own history directory — meta.json, stdout.log, stderr.log, result.txt, and telegram.json if it notified. Then the next run’s prompt gets a hint pointing at that directory.
That last bit is small and it changes what these jobs can be. The agent can read its own past output without you building any of that plumbing. “What changed since yesterday”, “did this regress”, “compare to last week” — all of it works because the history is on disk and the agent has been told where to look.

MCP mode

AICODEBOX_MCP_MODE=1. In API mode it mounts at /mcp on the same port — no extra process. Under Telegram, cron, or plain passthrough it runs as a sidecar uvicorn on AICODEBOX_MCP_MODE_PORT (default 8081).
Five tools: run_prompt, list_files, read_file, write_file, delete_file. Point Claude Desktop, Cursor, or another agent at it and your coding agent becomes a tool that other agents can call.
Auth is AICODEBOX_MCP_MODE_TOKEN — its own bearer, with no fallback to the API token. That’s deliberate. MCP is a separate surface with separate exposure, and quietly accepting the API bearer there would mean handing every API client an agent-execution tool they were never scoped for. There’s a ?apiToken= query param for clients that can’t set headers.

Configuration, With An Actual Convention

Everything is env vars, and the naming follows one rule: <MODE>_MODE is the on/off flag, <MODE>_MODE_<KNOB> is its config, and anything not mode-scoped is bare.

AICODEBOX_ADAPTER            # required — pkg.module:Class
AICODEBOX_AGENT_BINARY       # required — the CLI binary name
AICODEBOX_WORKSPACE          # default /workspace
AICODEBOX_AVAILABLE_MODELS   # required for API mode
AICODEBOX_API_MODE           # 0/1  + _PORT, _TOKEN
AICODEBOX_TELEGRAM_MODE      # 0/1  + _TOKEN, _CONFIG, _OVERRIDES
AICODEBOX_CRON_MODE          # 0/1  + _FILE
AICODEBOX_MCP_MODE           # 0/1  + _PORT, _TOKEN

The model list resolves in two steps: AICODEBOX_AVAILABLE_MODELS wins if it’s set, otherwise it falls back to whatever your adapter declares in its available_models class var. So the env var isn’t strictly mandatory — it’s the override. Declare the list in the adapter and you never need to set it.
What is mandatory is that one of those two produces something. If both come back empty, API mode refuses to boot — logs the reason and exits non-zero rather than starting up broken. That’s the correct call: /v1/models needs a real list and there’s no safe fallback, because the adapter’s name is not a model name. Better to fail loudly at boot than to serve a garbage model list that breaks somebody’s client three layers downstream.

The Family

Three images run on this base right now, all of them currently pinned to psyb0t/aicodebox:v0.14.0:

  • claudebox — Claude Code. Its v2.0.0 was a full rebase onto this base; it now contributes ClaudecodeAdapter and inherits every surface.
  • pibox — pi-coding-agent, pointed at whatever LLM you like. This is the reference child: it uses the base verbatim and adds PiAdapter.
  • codexbox — OpenAI’s Codex CLI, via CodexAdapter.

How the agent actually gets installed

All three happen to install their agent with npm install -g, but don’t read that as the recipe — it’s just that claude-code, pi and codex all ship as npm globals. The base has no opinion about how your binary got there.
The actual contract is two things: the binary named by AICODEBOX_AGENT_BINARY has to exist on PATH, and your adapter package has to be importable. That’s it. Whatever gets you there is your business:

  • npmRUN npm install -g @vendor/[email protected]. Node 22 LTS is already in the base, which is exactly why the three existing children took this route.
  • apt — it’s Ubuntu 24.04 with sudo and a working apt. RUN apt-get update && apt-get install -y your-agent is fine if somebody actually ships a deb.
  • pip / uv — Python 3.12 is there, and so is uv, digest-pinned. That’s how every child installs its adapter already: uv pip install --system --break-system-packages --no-deps /opt/yourpkg.
  • A compiled binary — Go, Rust, whatever. Note the base does not carry a Go or Rust toolchain, so don’t expect go install to work out of the box. Either bring the toolchain in your own layer, or do the sane thing and COPY --from= a build stage so the compiler never lands in the runtime image at all:
FROM golang:1.26 AS build
RUN go install github.com/someone/[email protected]
FROM psyb0t/aicodebox
COPY --from=build /go/bin/some-agent /usr/local/bin/some-agent
COPY myadapter /opt/myadapter
RUN uv pip install --system --break-system-packages --no-deps /opt/myadapter
ENV AICODEBOX_ADAPTER=myadapter.adapter:MyAdapter \
    AICODEBOX_AGENT_BINARY=some-agent

Same five surfaces out the other end. The base never learns what language your agent was written in, and that’s the whole point — it only ever shells out to a name on PATH and hands the bytes to your parse_output.
pibox is the honest minimum. npm install the agent, uv pip install the adapter package, set exactly two env vars — AICODEBOX_ADAPTER and AICODEBOX_AGENT_BINARY — and hand it a small branded entrypoint that aliases PIBOX_* to AICODEBOX_*. That’s the whole child image. It’s the reference implementation for a reason.
The other two are bigger, and that’s the interesting part. claudebox sets five env vars, codexbox four, and both ship an extra launcher script on top of the adapter. Not because the base is leaky, but because each agent drags in its own state problem that an agent-agnostic base is not allowed to know about:

  • claudebox sets CLAUDE_CONFIG_DIR so Claude Code writes .claude.json and its credentials onto the bind mount instead of an unmounted $HOME — otherwise you re-do the theme picker and the login on every single container recreate. Only claudebox knows the payload is Claude Code, so only claudebox can set that.
  • codexbox sets CODEX_HOME for the same reason (an API key or a ChatGPT subscription OAuth token that must survive a recreate) — and has to mkdir and chown it at build time, because codex errors out at startup if CODEX_HOME points at a directory that doesn’t already exist.
  • Both point AICODEBOX_AGENT_BINARY at a launcher script rather than the agent binary directly. claudebox’s restores the interactive defaults the agent-agnostic base deliberately dropped: --continue with a fallback, --permission-mode bypassPermissions, and the always-skills --append-system-prompt injection. Server modes bypass the launcher entirely and build argv through the adapter.

That’s the real shape of this abstraction, and I’d rather describe it accurately than pretend every child is four lines. The base owns everything that isn’t agent-specific. What’s left in each child is exactly the stuff that is: the adapter that knows this binary wants -p while that one wants --prompt, that one emits stream-json while another prints plain text — plus whatever config-persistence quirk that particular agent inflicts on you.
Everything else — the API, the OpenAI shim with its schema retries and tool calling, the Telegram bot, the cron scheduler with its history, the MCP server — is inherited. Fix a bug in the base, tag it, bump the pin in three children, done. Which is the entire point, and the reason claudebox v2 exists at all.

Running One

docker run --rm -p 8080:8080 \
  -e AICODEBOX_API_MODE=1 \
  -e AICODEBOX_API_MODE_TOKEN=$(openssl rand -hex 16) \
  -e AICODEBOX_AVAILABLE_MODELS=fast,smart \
  -v "$PWD/workspace:/workspace" \
  your/child-image:latest

Then talk to it like it’s OpenAI, or hit /run directly, or point an MCP client at it, or never touch HTTP at all and just let cron fire it at 9am.

The Bottom Line

The agent you’re using today is not the agent you’ll be using in a year. That’s not pessimism, it’s just the release cadence — this space rewrites itself every few months, and anything you build tightly coupled to one CLI is scaffolding with an expiry date.
So don’t couple to it. Put the surfaces in a base image, put the agent behind a two-method adapter, and when the next one lands you write forty lines instead of forking nine hundred. I’ve done it three times now. The third took an afternoon.
github.com/psyb0t/docker-aicodebox
Licensed under WTFPL — because a base image that exists specifically so you don’t have to fork it would be a stupid thing to put a restrictive license on.