Thought
Making Pi Feel Local, Safely
A local-feeling agent, contained behind snapshots.
I wanted to run Pi like a local command.
The wrapper and Docker setup I use are here: j1n-park/pi-docker.
pi
No long Docker command. No special setup for each repo. I wanted to cd into a project, run pi, and have it work.
But I did not want a coding agent running directly on my host machine.
A coding agent is not only a chat window. It can read files, edit files, run shell commands, install tools, and change the environment around it. Pi’s quickstart says the default tools include file read/write/edit and bash execution. It also says Pi runs in the current directory and can modify files there. It recommends Git or another checkpoint workflow if I want easy rollback. (pi.dev)
So the question was simple:
How do I make Pi feel local, without giving it my whole host machine?
The answer I ended up with is a Docker-backed agent workstation.
It is not a normal project devcontainer. It is a private, stateful environment for Pi, with snapshots before each run.
What I Wanted
I cared about four things.
First, the command should feel local. I should be able to run pi from any repo.
Second, the agent should be contained. It should not get my host SSH keys, GPG keys, GitHub credentials, cloud credentials, or Docker socket.
Third, the environment should keep state. If the agent installs a package or configures a tool, that should still be there next time.
Fourth, I wanted rollback. A stateful environment is useful, but it can also get messy. I wanted a way to go back.
The model looks like this:
host shell
pi
↓
temporary Docker container
↓
Pi runs inside the container
↓
on exit, container state is committed back to the current image
Before each run, the current image is saved as a snapshot.
A = current image
run Pi
A → B
after exit:
B becomes current
A stays as a snapshot
So the agent environment can grow over time, but it is not one-way.
Why Not a Disposable Container?
The simplest version would be this:
docker run --rm -it \
--mount type=bind,src="$PWD",dst=/workspace,rw \
pi-agent-image
That gives isolation, and it mounts the project into the container.
But --rm throws away the container filesystem when the session ends.
That is fine for a clean build environment. It is not great for an agent. An agent may install tools, add caches, set up config, or improve its own workspace over time. I did not want to lose that after every run.
I also thought about putting only /home/agent in a Docker volume. That would keep Pi config and user caches, but it would not keep system packages such as:
sudo apt install cmake
sudo apt install libssl-dev
sudo apt install sqlite3
Those live in the container filesystem.
So a disposable container was too stateless.
Why Not a Persistent Container?
The other easy answer is one long-lived container.
That keeps everything: packages, config, cache, login state, and local changes.
But Docker mounts are fixed when the container is created.
If I create the container in repo-a, then /workspace points to repo-a. If I later go to repo-b, restarting that same container does not remount /workspace to repo-b.
I wanted this:
cd ~/code/repo-a
pi
# works on repo-a
cd ~/code/repo-b
pi
# works on repo-b
A persistent container keeps state, but the workspace is stuck.
So I use temporary containers, but persistent image state.
The Image Model
There are three kinds of images.
base image:
clean image built from Dockerfile
current image:
the default Pi workstation state
snapshot images:
older current images kept for rollback
Each run does roughly this:
1. Find the target directory.
2. Make sure the base image exists.
3. Make sure the current image exists.
4. Snapshot the current image.
5. Start a temporary container from the current image.
6. Mount the target directory at /workspace.
7. Run Pi.
8. Commit the container back to the current image.
9. Remove the temporary container.
The target directory is chosen like this:
if inside a Git repo:
mount the Git repo root to /workspace
otherwise:
mount the current directory to /workspace
This gives me the two things I wanted:
workspace:
changes per run
agent environment:
keeps state across runs
/workspace is a bind mount. It is not saved into the image. That is important.
Project changes belong to Git. Agent environment changes belong to the Docker image.
What Gets Saved
The image snapshot saves the agent environment.
Saved:
Pi installation
Pi login/session/config
/home/agent
apt-installed packages
npm/pip/go-installed tools
system changes under /usr, /etc, /opt, etc.
container caches
Not saved:
/workspace project files
Rollback is split in two:
Agent environment rollback:
Docker image snapshots
Project source rollback:
Git
I like this split. It keeps the two kinds of state separate.
Installing Pi
Pi can be installed through npm or the Linux/macOS installer, and login is done from inside Pi with /login for subscription providers. (pi.dev)
I do not install Pi during docker build.
The Dockerfile only prepares the base OS and tools. The wrapper installs Pi inside the interactive container if it is missing.
That keeps the base image simple. Pi itself, login state, config, and caches become part of the evolving current image.
The base Dockerfile looks like this:
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
bash \
ca-certificates \
curl \
git \
jq \
less \
ripgrep \
sudo \
build-essential \
python3 \
python3-pip \
cmake \
pkg-config \
sqlite3 \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y nodejs \
&& node --version \
&& npm --version \
&& rm -rf /var/lib/apt/lists/*
ARG USERNAME=agent
RUN groupadd "${USERNAME}" \
&& useradd --gid "${USERNAME}" -m -s /bin/bash "${USERNAME}" \
&& usermod -aG sudo "${USERNAME}" \
&& echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}" \
&& chmod 0440 "/etc/sudoers.d/${USERNAME}"
USER ${USERNAME}
WORKDIR /workspace
ENV HOME=/home/${USERNAME}
ENV PATH=/home/${USERNAME}/.local/bin:/home/${USERNAME}/.npm-global/bin:${PATH}
CMD ["/bin/bash"]
At runtime, the wrapper does this if Pi is missing:
if ! command -v pi >/dev/null 2>&1; then
curl -fsSL https://pi.dev/install.sh | sh
fi
exec pi "$@"
Login State
Pi stores auth state under the user’s home directory. The provider docs mention ~/.pi/agent/auth.json for stored credentials and refresh state. (pi.dev)
In this setup, that file lives inside the image state.
That is convenient, but it also means the images are private. They may contain login state, config, caches, and other personal data.
I do not push these images to a registry. I treat them like local workstation state.
Boundaries
The wrapper does not mount my host home directory.
It also clears common credential environment variables:
GITHUB_TOKEN
GH_TOKEN
SSH_AUTH_SOCK
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_SESSION_TOKEN
GOOGLE_APPLICATION_CREDENTIALS
KUBECONFIG
NPM_TOKEN
I do not mount:
~/.ssh
~/.gnupg
host Docker socket
cloud config
Apple signing material
The agent can edit /workspace and run commands inside the container. It can install packages inside its own environment.
But it cannot use my SSH key, sign commits with my GPG key, push with my GitHub credentials, access my cloud account, or control the host Docker daemon.
This is containment, not perfect security. Since /workspace is writable, the agent can change scripts, build files, or hooks that I might later run on the host. I still need to review changes.
Commits, Signing, and Push
I am okay with the agent making unsigned commits inside the repo.
I am not okay with it signing or pushing.
To me, those are different actions:
commit:
local checkpoint
signed commit:
I approve this content
push:
publish it outside my machine
So the split is:
Agent:
edit files
run tests
create unsigned commits
Me:
review diff
sign commits
push
That keeps the agent useful, but I still keep final control.
No Docker Socket
I do not mount the host Docker socket into the agent container.
That would look like this:
-v /var/run/docker.sock:/var/run/docker.sock
It is tempting, because then the agent could run Docker commands.
But access to the Docker socket is close to access to the host. A process can create containers, mount host paths, and escape the boundary I was trying to keep.
So the agent can write Docker files and scripts:
Dockerfile
docker-compose.yml
scripts/agent/docker-build.sh
scripts/agent/docker-test.sh
Then I run the Docker command on the host and paste logs back if needed.
The agent can help write and debug. It does not get to operate my host Docker daemon.
The Wrapper
The wrapper exposes pi as a shell function.
A simplified version looks like this:
pi() {
target="$(_pi_target_dir)"
_pi_ensure_base_image
_pi_ensure_current_image
snapshot="$(_pi_snapshot_current)"
container="pi-run-$$-$(date +%s)"
docker run -it \
--name "$container" \
--workdir /workspace \
--mount type=bind,src="$target",dst=/workspace,rw \
--env HOME=/home/agent \
--env GITHUB_TOKEN= \
--env GH_TOKEN= \
--env SSH_AUTH_SOCK= \
"$PI_AGENT_CURRENT_IMAGE" \
bash -lc '
git config --global user.name "Agent"
git config --global user.email "agent@example.local"
git config --global commit.gpgsign false
if ! command -v pi >/dev/null 2>&1; then
curl -fsSL https://pi.dev/install.sh | sh
fi
exec pi "$@"
' bash "$@"
exit_code=$?
docker commit \
--change "LABEL pi.agent.previous_state=$snapshot" \
--change "LABEL pi.agent.committed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$container" "$PI_AGENT_CURRENT_IMAGE"
docker rm "$container"
return "$exit_code"
}
The real version also has helper commands:
pi-shell
pi-status
pi-snapshots
pi-rollback
pi-reset-system
pi-reset-all
pi-rebuild-base
pi-prune
pi-orphans
pi-recover
pi-clean-orphans
pi-unlock
Once the environment keeps state, these small tools become useful.
Failure Recovery
If the terminal closes at the wrong time, the wrapper may not reach docker commit.
Project files are still okay because /workspace is a host bind mount. But container-internal state may be left inside the temporary container.
So I keep commands for that:
pi-orphans
pi-recover <container>
pi-clean-orphans
That is enough for the failure mode I care about.
I thought about making everything tmux-based, but that pushed the design back toward a persistent container. I prefer temporary containers with explicit recovery.
One Agent at a Time
I do not run multiple agents by default.
The reason is the shared current image.
If two sessions start from the same image and both commit back, the last one wins.
A starts from current
B starts from current
A exits → current = A
B exits → current = B
System-level changes from A can disappear.
So the default is a lock: one Pi session at a time.
If I want parallel work later, I would split both workspace and image state:
git worktree add ../repo-agent-a -b agent/a main
git worktree add ../repo-agent-b -b agent/b main
PI_AGENT_PROFILE=a pi
PI_AGENT_PROFILE=b pi
That can come later.
What I Use Now
The current setup is:
Run:
pi
Workspace:
current Git repo root or current directory mounted to /workspace
Agent state:
Docker current image
Before run:
snapshot current image
After run:
commit container back to current image
Rollback:
tag previous snapshot as current
Host access:
no home mount
no SSH/GPG mount
no Docker socket
no cloud credentials
This is the balance I wanted.
Pi feels like a local command, but it does not run on my host. The agent can grow its own environment, but I can roll it back. It can edit project files, but Git handles project rollback. It gets a useful shell, but not my keys, my signing identity, or my host Docker daemon.
What I Might Improve
There are a few things I may improve later.
Branching would be useful. Right now there is one current image and snapshots. Named image branches could make experiments cleaner.
Image size also needs care. Committing after each run is useful, but images grow. Pruning helps, but better cleanup would be nice.
Parallel work is another possible improvement. I do not want agents fighting over one workspace and one current image. But worktrees plus profile-specific images could work.
Recovery could also be smoother. Orphaned containers and stale locks should be easy to inspect without remembering Docker commands.
Final Thought
The main point is that I am not building a project devcontainer.
I am building a personal agent workstation.
A devcontainer should usually be reproducible and mostly stateless. An agent workstation can evolve, but it needs checkpoints.
So the setup is more specific than “run Pi in Docker”:
run Pi in Docker
snapshot before each run
commit after each run
keep project files in Git
keep host credentials out
treat the agent environment as private local state
It is not perfect. But it matches how I want to use a coding agent: close enough to feel local, contained enough to feel safe, and stateful enough to get better over time.