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.

More thoughts

What OSCP Taught Me About Problem Solving

Learning to break systems, and rebuild confidence.

Why I'm Still Using My M2 MacBook Air

Why my 16GB M2 MacBook Air is still enough when paired with a Linux desktop.

Making Mosh My Default Remote Terminal

A remote shell that stays connected, scrollable, and quiet.