A containerized sandbox for running coding agents (Claude Code, Gemini CLI, OpenAI Codex, GitHub Copilot) with --dangerously-skip-permissions / --dangerously-bypass-approvals-and-sandbox enabled by default.
Agents work best when they can freely run shell commands, edit files, install packages, and poke at databases ��� but you don't want them doing that against your host. This image gives each session its own throwaway Linux environment with:
- The project you're working on mounted at
/app. - Language runtimes, databases (PostgreSQL, LavinMQ, Redis), and common tools preinstalled, so agents don't spend turns bootstrapping.
- OAuth credentials and API keys mounted from
~/ai/settings, with multi-profile support and automatic token refresh. - Shell history, agent session history, cloned repos, and installed gems persisted on the host across container restarts.
- A shared directory mounted at
/share(from~/ai/share) for passing files between the host and containers. mitmproxyavailable for inspecting what the agent actually sends over the wire.
Drop per-profile OAuth credentials in $HOME/ai/settings as
.credentials.<profile>.json. They get mounted into the container and copied
into ~/.claude/ when you launch claude with a matching profile. See the
OAuth Login section for how to generate these.
Optionally, add AGENTS.md to $HOME/ai/settings — it becomes CLAUDE.md
for Claude Code, GEMINI.md for Gemini CLI, and is copied into Codex's
session config.
Optionally, add sentry.token to $HOME/ai/settings to enable the
Sentry MCP server in Claude Code. See
MCP Servers.
# start podman and share the current working directory
bin/ai
# start podman and auto-launch "c <profile>" once the container is up.
# before launching, the shared "~/ai/settings" token for <profile> is
# refreshed on the host (silent refresh, falling back to interactive login
# only if it has fully expired) so every session reuses a valid token.
bin/ai <profile>
# start podman and auto-launch "cx" once the container is up.
bin/ai cx
# resume a prior Claude session: passed through to "c --resume <id>" on launch.
# works with or without a profile (c auto-detects it from the session).
bin/ai --resume <session-id>
bin/ai <profile> --resume <session-id>
# resume a prior Codex session: passed through to "cx --resume <id>" on launch.
bin/ai cx --resume <session-id>
# publish extra ports from the container to the host. each entry is either
# "PORT" (host==container) or "SRC:DST" (host:container); comma-separate many.
bin/ai --ports 9999 # host 9999 -> container 9999
bin/ai --ports 8888:7777 # host 8888 -> container 7777
bin/ai <profile> --ports 9999,8888:7777
# enable Claude Code remote control for the session (off by default).
# equivalently set AI_REMOTE=1 in your shell. see "Remote control" below.
bin/ai <profile> --remote
AI_REMOTE=1 bin/ai <profile>
# enable fast mode for the session (off by default).
# equivalently set AI_FAST=1 in your shell. see "Fast mode" below.
bin/ai <profile> --fast
AI_FAST=1 bin/ai <profile>
# start services (and run "bundle install" if Gemfile exists).
# also runs automatically as part of "c" below.
s
# launch claude with a specific oauth profile (runs "s" first)
c <profile>
# or launch claude with an Anthropic API key
c --apikey sk-ant-...
# resume a prior Claude session (searches /history for the session id; a prefix is enough).
# profile is auto-detected from the session's saved .profile file.
c --resume <session-id>
# launch gemini
g
# launch openai codex
cx
# resume a prior Codex session (searches /history for the session id; a prefix is enough).
cx --resume <session-id>
# exit the container
xcx launches Codex through a temporary symlink named after the host directory
captured by bin/ai, so Codex's terminal title and project/status fields show
that host directory name instead of /app. Its generated statusline shows the
project, git branch, model/reasoning, context used, and thread id. TUI
notifications are disabled for quieter terminal sessions.
cx --resume <id> searches archived Codex rollouts under /history and
reuses the original Codex home before launching codex resume <id>.
Your Anthropic API key in 1Password.
brew install podman
# init the VM, enable zram swap, set kernel.keys quotas
bin/setup-vm
# normal builds use the pinned agent versions in "versions/" and do not
# check upstream "latest" endpoints.
./build_image
# update pinned Claude Code and Codex version files, then rebuild
./build_image --update-agents
# update only one pinned agent version file
./build_image --update-claude
./build_image --update-codex
# rebuild all layers and pull latest base image
./build_image --forceFirst-time setup to get OAuth credentials.
Claude Code:
# inside the container, or via podman run
refresh-tokens --login
refresh-tokens --login <profile>This generates an OAuth authorization URL. Open it in your browser, sign in, and paste the code back into the terminal. Credentials are saved to ~/.claude/.credentials.json (or .credentials.<profile>.json).
OpenAI Codex:
# inside the container
codex-login
# or from the host
bin/codex-loginThis standalone helper starts Codex's "Sign in with Device Code" flow without
running the Codex CLI. Open the displayed URL, enter the one-time code, and
finish sign-in in your browser. Credentials are saved to
/settings/codex/auth.json in the container, or
$HOME/ai/settings/codex/auth.json from the host.
OAuth tokens expire periodically. A systemd service (refresh-tokens.service) runs in every container, keeping ~/.claude/.credentials*.json files fresh automatically.
# check service status
systemctl status refresh-tokens
# view logs
journalctl -u refresh-tokens
# follow logs
journalctl -u refresh-tokens -f
# one-shot refresh (e.g. before launching a session)
refresh-tokens --onceThe service can also run as a standalone container to refresh /settings credentials:
podman run -d --name token-refresh \
--env CREDENTIALS_DIR=/settings \
--volume ${HOME}/ai/settings:/settings \
ai:latest /usr/local/bin/refresh-tokens --daemonEnvironment variables:
| Variable | Default | Description |
|---|---|---|
CREDENTIALS_DIR |
~/.claude |
Directory containing .credentials*.json files |
CHECK_INTERVAL |
300 |
Seconds between checks |
REFRESH_BEFORE |
3600 |
Seconds before expiry to trigger refresh |
To enable Sentry's MCP server so the agent can fetch issues, events, and
stack traces directly, drop your Sentry
user auth token in
$HOME/ai/settings/sentry.token (just the token, no quotes or whitespace).
When you launch c <profile>, an mcpServers.sentry entry is injected into
the session's ~/.claude.json that runs
@sentry/mcp-server locally over
stdio with SENTRY_ACCESS_TOKEN set. We don't use the hosted
mcp.sentry.dev because its OAuth flow expects a callback on
localhost:62880 inside the browser host — unreachable from this container.
For self-hosted Sentry, additionally drop the hostname in
$HOME/ai/settings/sentry.host (e.g. sentry.example.com) — it's passed
through as --host=....
No sentry.token → no MCP server registered.
Remote control lets you monitor and steer a running Claude Code session from claude.ai or the Claude mobile app. The session keeps running in the container — only its I/O is mirrored, over the same outbound HTTPS the agent already uses.
It is off by default and opt-in per session, because it exposes the session (filesystem, MCP servers, every tool call) to anyone with your claude.ai login. Turn it on with either:
bin/ai <profile> --remote # one-off flag
AI_REMOTE=1 bin/ai <profile> # or set the env in your shellbin/ai forwards this into the container as AI_REMOTE=1; c then sets
remoteControlAtStartup in the session's ~/.claude/settings.json — the same
key the /config "Enable Remote Control for all sessions" toggle writes, so the
bridge starts automatically. Running c directly honours the same --remote
flag and AI_REMOTE env. To make it the default for every session, export
AI_REMOTE=1 in your host shell profile.
Remote sessions are labelled by the host directory in the claude.ai/mobile
list — c sets CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX from HOST_DIR, so
they show as <dir>-<random-words> instead of the container hostname.
To enable it on an already-running session, run /remote-control (or /rc)
in the TUI; a /rc active link then appears in the footer.
Fast mode runs Opus with higher-speed output. It is off by default and opt-in per session, because it draws from usage credits at a higher rate and has separate rate limits. Turn it on with either:
bin/ai <profile> --fast # one-off flag
AI_FAST=1 bin/ai <profile> # or set the env in your shellbin/ai forwards this into the container as AI_FAST=1; c then sets
fastMode in the session's ~/.claude/settings.json — the same key the
/fast toggle writes. Running c directly honours the same --fast flag and
AI_FAST env. To make it the default for every session, export AI_FAST=1 in
your host shell profile. Toggle it within a session with /fast [on|off].
c also exports CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1 in the fast path.
Without it the persisted fastMode is evaluated once at startup, while the
async fast-mode availability check is still pending, so in a fresh container it
resolves to off and never re-applies; skipping that check lets the setting
engage immediately.
zsh things:
# pod # list all running containers
# pod <id> # launch bash shell in selected container
# pod last # launch bash shell in the youngest container
function pod() {
[ $# -lt 1 ] && podman ps && return 0
[ "$1" = "last" ] && podman exec -it $(podman ps | tail -1 | cut -d ' ' -f 1) ${2:-bash} && return
local container
container=$1
podman exec -it $container ${2:-bash}
}Start it capturing everything:
./mitmdump --mode regular --listen-port 8080 --ssl-insecure --set flow_detail=3 -w claude.flowmitmproxy generates its CA at ~/.mitmproxy/mitmproxy-ca-cert.pem on first run.
export NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem
export HTTPS_PROXY=http://127.0.0.1:8080Start the agent
claudeOutput the (partially) binary dump as text (--mode picking another port is important if proxy already running)
./mitmdump --mode regular@8082 --set flow_detail=3 -r claude.flow --set export_format=curltcpdump
podman run --rm -it --cap-add=NET_RAW --cap-add=NET_ADMIN --net=container:<container> nicolaka/netshoot tcpdump -i eth0podman
# to see current settings
podman machine inspect
# when we can't build because we're out of space
podman system prune--all
# initial setup, or after `podman machine reset`:
# inits the VM (300 GB disk, 8 GB RAM), enables zram swap, and persists
# kernel.keys.maxkeys / maxbytes so we don't hit the keyring quota
# ("crun: join keyctl ... Disk quota exceeded")
bin/setup-vm