Self-hosted web client and PWA for Claude Code sessions
4.9K
A self-hosted web UI for Claude Code sessions — a Python server that runs the CLI as a subprocess and serves a vanilla-JS PWA on top. Inspired by code-server. It can also drive the OpenAI Codex CLI as a second provider (experimental).
Thanks to the Auto Journal, you can easily pull up the full history of any topic or file you've already worked on. After each turn finishes, the session is forked in the background to a fast model (Haiku by default) that summarizes the turn — and the summary is stored in a local DuckDB and in the project's shadow git, as the commit message over everything that changed during the turn. It's not just for you: the optional shadow-git-helper agent gives Claude the same access, digging through past turns to brief the session with full historical context.
The image ships Python 3.13 and Node 20; the agent CLIs (@anthropic-ai/claude-code, @openai/codex) are installed from npm on first start into the /data volume. Multi-arch (linux/amd64, linux/arm64).
GitHub repo: https://github.com/wrotek/painapple-code — full docs, source, issues, screenshots.
pAInapple Code effectively gives whoever holds its password a remote shell on the host. Claude Code can execute commands, edit files, and reach the network on the user that started the container. The built-in auth is a single-password gate — useful, but not a substitute for proper network controls.
docker run example below binds 127.0.0.1:8765. Don't change that to 0.0.0.0 (or a public IP) without putting a reverse proxy with TLS and ideally a VPN or SSH-tunnel layer in front.docker logs persists). Reveal the login URL with painapple password (pip CLI), or read it directly: docker exec painapple-code awk '/^password:/ {print $2}' /home/app/.config/painapple-code/config.yaml. Open it once — the cookie keeps you logged in afterwards.YOLO permission mode anyone with the password can run arbitrary commands.Agent CLIs are installed on first start, not bundled. The image ships Node.js but no agent CLI. On first boot the entrypoint runs npm install -g for @anthropic-ai/claude-code@2 and @openai/codex@latest into the /data volume, so the download is yours under your own agreement with the vendor — @anthropic-ai/claude-code is proprietary (© Anthropic PBC, all rights reserved) and using it means accepting Anthropic's Commercial Terms. Supply your own credentials (ANTHROPIC_API_KEY env var, or an OAuth login persisted via the ~/.claude volume). First start therefore needs network access to the npm registry and takes a few seconds longer; set PAINAPPLE_SKIP_AGENT_CLI=1 to opt out, or PAINAPPLE_AGENT_CLIS to change the set.
docker run -d --name painapple-code \
-p 127.0.0.1:8765:8765 \
-v "$PWD:/workspace" \
-v "$HOME/.painapple-code/.claude:/home/app/.claude" \
-v painapple-data:/data \
wrotek/painapple-code:latest
# Capture the bootstrap URL (contains the auto-generated password)
docker logs painapple-code 2>&1 | grep -E 'http(s)?://' | head -1
Open the printed URL in any modern browser. The auth cookie persists across reloads.
To seed the bundled CLI with an existing Anthropic OAuth login, copy your host credentials in before first start:
mkdir -p ~/.painapple-code/.claude
cp ~/.claude/.credentials.json ~/.painapple-code/.claude/
Or pass an API key instead of OAuth:
docker run -d ... -e ANTHROPIC_API_KEY=sk-ant-... wrotek/painapple-code:latest
The pip package manages this image for you — docker is a built-in run mode of the unified painapple CLI: an interactive setup wizard (workspace, credentials, network/TLS), pull, lifecycle, logs, and password reveal. Docker and Podman are auto-detected:
pipx install painapple-code
painapple --in-docker # sandbox the current directory (image auto-pulled on first run)
painapple setup myapp # or a durable named sandbox (pick "Docker" in the wizard)
painapple start myapp # detached, --restart unless-stopped
painapple password myapp # show the login URL + password
Everything below works without it — the CLI just generates the same docker run for you and remembers your answers in ~/.painapple-code/profiles/NAME/profile.yaml.
services:
painapple:
image: wrotek/painapple-code:latest
init: true
restart: unless-stopped
ports: ["127.0.0.1:8765:8765"]
environment:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
volumes:
- painapple-data:/data
- painapple-config:/home/app/.config/painapple-code
- ${HOME}/.painapple-code/.claude:/home/app/.claude
- ${WORKSPACE:?set WORKSPACE to your project dir}:/workspace
volumes:
painapple-data: {}
painapple-config: {}
| Tag | When |
|---|---|
vX.Y.Z | Pinned to a specific release. Recommended for production. |
latest | Newest stable SemVer release. Pre-releases (-rc1, -beta) do not move latest. |
edge | Manual builds off main. May be broken. Do not use in production. |
SemVer tags are immutable — once v0.1.0 is published it will not be re-pushed. If a release has a bug, the next patch (v0.1.1) ships the fix.
For the strongest guarantee, pin by digest: wrotek/painapple-code@sha256:….
| Path | Purpose | Required? |
|---|---|---|
/workspace | The project directory Claude operates on. Bind-mount your repo here. | Yes — the entrypoint refuses to start without it. |
/data | All application state (sessions, logs, Shadow Git, DuckDB, uploads). Use a named volume. | Recommended |
/home/app/.claude | Claude CLI state (OAuth login, settings). Bind-mount from host to reuse your login. | Optional |
/home/app/.config/painapple-code | Auth config file (config.yaml with the login password). | Recommended — survives data wipes |
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY | Claude API key (alternative to OAuth via ~/.claude mount). |
PAINAPPLE_CODE_HOME | Override the state directory. Defaults to /data in the image. |
PAINAPPLE_ALLOWED_ORIGINS | Comma-separated extra trusted browser origins (CSRF/Origin gate + CORS). Rarely needed — same-origin traffic (any proxied hostname or LAN IP) is accepted automatically; set this only for a genuinely cross-origin front-end. |
PAINAPPLE_SKIP_AGENT_CLI | Set to 1 to skip the first-run agent-CLI install (bring your own, or run a UI/terminal-only instance). |
PAINAPPLE_AGENT_CLIS | Override what gets installed, as space-separated binary=npm-spec pairs. Default: claude=@anthropic-ai/claude-code@2 codex=@openai/codex@latest. Drop one to skip it, or pin a version. |
PAINAPPLE_AGENT_CLI_PREFIX | Where they install. Defaults to /data/npm-global, i.e. on the persistent volume. |
DISABLE_AUTOUPDATER | Pre-set to 1 — keeps the Claude CLI on the version the entrypoint pinned instead of updating itself past the @2 ceiling. |
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC | Pre-set to 1 — suppresses CLI update-check / telemetry calls. |
| Port | Protocol | Purpose |
|---|---|---|
8765 | HTTP + WebSocket | pAInapple Code UI and API. The image listens on 0.0.0.0:8765 inside the container; the host port mapping is what gates network exposure. |
Every HTTP and WebSocket request needs a password. The server generates one on first start and stores it owner-only in ~/.config/painapple-code/config.yaml (under /home/app/ inside the container). On a loopback bind it logs a bootstrap URL with the token embedded as ?tkn=… — open it once, the cookie does the rest. Non-loopback binds (LAN, 0.0.0.0, inside the container) hide credentials from stdout by default, since a server's console tends to end up in journald or docker logs; retrieve them with painapple password, or opt back in with --show-password.
# Reveal the password — prints ready-to-open login URLs
painapple password # add a profile name for named deployments
# …or read the config file directly (in a container not managed by the CLI,
# prefix with `docker exec painapple-code` and use the /home/app/… path)
awk '/^password:/ {print $2}' ~/.config/painapple-code/config.yaml
# Rotate: delete the config and a new password is generated
rm ~/.config/painapple-code/config.yaml # then restart the server
Three auth paths: the painapple_auth cookie (set automatically after first login), ?tkn=<api_token> in any URL, or Authorization: Bearer <api_token> for curl and scripts. None of them carries the password itself — cookies and tokens are HMAC-derived from it, so a shared bootstrap link or a CI secret can't open the login form, and each side is revocable independently (log out every browser, or kill every script token, without touching the other). Details in the security notes.
| Platform | Status |
|---|---|
linux/amd64 | Native build, smoke-tested in CI |
linux/arm64 | Native build (Apple Silicon, Raspberry Pi 4/5, AWS Graviton) |
Verify the platform after pull:
docker buildx imagetools inspect wrotek/painapple-code:latest
It is a thin wrapper around Claude Code — every prompt streams through the official CLI/Agent SDK, and any session started here can be resumed in the plain CLI with claude --resume <id>. It never modifies Claude's system prompt, tool policy, or behavior — no injected planning steps, no hidden instructions. What it does add to a prompt is the context you attached: the output of !bang commands you ran, paths of files you uploaded, and snippets from the comments stash are prepended as plain text. The same wrapper can drive the OpenAI Codex CLI, selected per session, resumable with codex exec resume <id> — the Codex path is newer and has had less testing than the Claude path.
It is not a hosted service. You run it, on your hardware, with your own Claude account.
It is not zero-config "code from anywhere". The client works as a PWA on a phone or iPad, but the networking between them is yours to wire up. The practical path: keep the default 127.0.0.1 bind and add a reverse proxy (Caddy, nginx) or a VPN for remote access — mobile browsers handle self-signed certificates poorly.
Full list on GitHub:
Ask (the default — every edit/command waits on a card), Plan (read-only), Accept-Edits, Don't Ask, Auto (Claude's AI classifier), YOLO.This is an MVP. Run it in a sandbox (container, VM, LXC, BSD jail). Do not expose it to the public internet without a reverse proxy with TLS, and ideally a VPN layer.
Content type
Image
Digest
sha256:0a08d450c…
Size
346.2 MB
Last updated
about 7 hours ago
docker pull wrotek/painapple-code