Skip to content

Per-session dev environments

A session is isolated in its files: its own branch, its own worktree. But by default it still builds and tests with your machine’s toolchain. Parallel sessions share one Node, one Python and one set of ports, and a session that needs a different stack has nowhere to go.

A hook can give the session an environment instead. It starts a container (or a pod, a VM, an SSH host) and reports one line back, and spwn runs the session inside it: the agent’s TUI, the shells you open on the session, its builds and tests, and the browser editor.

Terminal window
echo "::spwn:set:: exec=docker exec -it -w $SPWN_WORKTREE spwn-$SPWN_TERMINAL_ID"

spwn has no Docker code. It puts the prefix you report in front of each process it starts for the session and never looks inside it. See Talking back to spwn for the exact semantics of each key.

Host Environment
The agent’s TUI ✓
Shells opened on the session ✓
The browser editor (code-server) ✓
Builds, tests, package installs ✓
Scheduled runs (only if you report execHeadless) ✓
spwn’s hooks ✓
Per-turn commits, checkpoints, worktree create/remove ✓
Your own editor and git ✓

The environment isolates the toolchain, not the files. The worktree is shared, so you see the agent’s edits the moment it makes them, and spwn’s commits, checkpoints and Timeline keep working.

Walkthrough: one Docker container per session

Section titled “Walkthrough: one Docker container per session”

The runnable version is examples/hooks/docker-env. It’s three files:

.spwn/
env/Dockerfile # the session image
hooks/
session-created.d/20-container.sh # create the container, report the prefix
session-deleted.d/50-container.sh # remove it
Terminal window
mkdir -p .spwn
cp -R examples/hooks/docker-env/.spwn/hooks .spwn/
cp -R examples/hooks/docker-env/.spwn/env .spwn/
chmod +x .spwn/hooks/*/*.sh
docker build -t spwn-session-env .spwn/env
git add .spwn && git commit -m "Per-session Docker environments"

Commit them: repo hooks reach a session through its checkout. Build the image first, because the hook won’t pull it (more on that below). Set SPWN_ENV_IMAGE to use a different image.

The image carries tools, never source. It needs:

  • the agent CLI on its PATH. That’s what spwn runs inside the environment. Your host’s binary (a macOS build, say) can’t run in a Linux container. If the name differs, report it with execBin.
  • env, git and a shell, so no distroless or scratch base.
  • code-server, if you want the browser editor. spwn installs code-server for host sessions, but it can’t install one across a boundary it knows nothing about. The example image doesn’t include it; add it if you use the editor.

Everything else is yours to pin: the Node version, the system libraries, whatever this project actually needs.

20-container.sh is a repo hook, so it runs after spwn’s global 10-worktree.sh has made the worktree. It bails out early, leaving the session on the host, if Docker or the image is missing:

Terminal window
name="spwn-$SPWN_TERMINAL_ID"
image="${SPWN_ENV_IMAGE:-spwn-session-env}"
docker="$(command -v docker)" || { echo "docker not found; staying on the host"; exit 0; }
if ! "$docker" image inspect "$image" >/dev/null 2>&1; then
echo "image '$image' not present locally; build it with: docker build -t $image .spwn/env"
exit 0
fi

The image check matters because hooks are synchronous and have no timeout: a multi-minute docker pull here would look like a hung session.

Then it creates the container, if it doesn’t already exist:

Terminal window
gitdir="$(git -C "$SPWN_WORKTREE" rev-parse --path-format=absolute --git-common-dir)"
if ! "$docker" inspect "$name" >/dev/null 2>&1; then
"$docker" run -d --name "$name" \
--restart unless-stopped \
-v "$SPWN_WORKTREE:$SPWN_WORKTREE" \
-v "$gitdir:$gitdir" \
-v "$HOME/.claude:$HOME/.claude" \
-e "HOME=$HOME" \
-w "$SPWN_WORKTREE" \
"$image" sleep infinity >/dev/null
fi
"$docker" start "$name" >/dev/null 2>&1 || true

Every mount uses the same path on both sides. Two things depend on it, and both break silently if it’s wrong:

  • The transcript. spwn finds a session’s conversation by a slug of its working directory. A different path inside the container is a different slug, and session binding, the Timeline, turn detection and rewind all stop working, with no error.
  • git. A worktree’s .git is a file that points at the main repository by absolute path, so that path has to resolve inside the container too. The hook asks git for the shared git directory (--git-common-dir) rather than assuming $SPWN_PROJECT_DIR/.git, which is wrong when the project is a subfolder of the repo or when worktrees live outside the project.

The git directory mount does a second job: the browser editor’s unix socket lives there. Don’t drop it.

Mounting ~/.claude at the same path means the agent inside reuses your login and writes its transcript where spwn already looks for it.

Finally, the hook tells git the mounted paths are safe and hands spwn the way in:

Terminal window
"$docker" exec "$name" git config --global --add safe.directory "$SPWN_WORKTREE" || true
"$docker" exec "$name" git config --global --add safe.directory "$gitdir" || true
echo "::spwn:set:: exec=$docker exec -it -w $SPWN_WORKTREE $name"
echo "::spwn:set:: execHeadless=$docker exec -i -w $SPWN_WORKTREE $name"
echo "::spwn:set:: execShell=/bin/bash"
  • exec uses -it. The agent’s TUI needs a terminal to render at all, and -t is also what forwards window resizes.
  • execHeadless uses -i only. Scheduled runs parse the agent’s output as JSON lines, which a terminal would corrupt. Leave it out and scheduled runs stay on the host.
  • docker is an absolute path because panes are launched by spwn’s long-lived terminal daemon, whose PATH may not include what the hook’s does.

session-deleted fires before the worktree goes. Repo scripts run first, inside the worktree, and spwn’s global 90-worktree.sh removes the worktree afterwards:

.spwn/hooks/session-deleted.d/50-container.sh
name="spwn-$SPWN_TERMINAL_ID"
docker="$(command -v docker)" || exit 0
if "$docker" inspect "$name" >/dev/null 2>&1; then
"$docker" rm -f "$name" >/dev/null 2>&1 || true
fi

It uses rm -f rather than stop: a container created with --restart unless-stopped that is merely stopped comes back on the next Docker restart. Teardown steps are guarded with || true because a teardown should never be the reason a delete misbehaves.

A teardown hook can also read SPWN_EXEC, the prefix the session was given, if it needs to work out what to remove.

If the container disappears (docker rm, a Docker reset), the session’s stored prefix points at nothing and its panes won’t start. Open the session’s Hooks tab and click Run on session-created. The hook is idempotent, so it recreates the container, and spwn records the prefix it reports.

  • Credentials don’t follow the session in. spwn gives panes your GitHub token as paths: a git credential helper and a gh config directory under spwn’s data folder. The variables cross into the container, but the files they point at don’t. Inside, git falls back to whatever credentials the container has, and gh finds no config. That’s deliberate: a credential isn’t something to bind-mount into an image the project controls. If the environment needs GitHub, give it a token of its own (or, on Kubernetes, share spwn’s home volume, as the examples below do). Environments that only build and test need nothing.
  • It’s an environment boundary, not a sandbox. The container shares your ~/.claude, login included, and can write to your worktree and git directory.
  • File ownership on Linux. The example container runs as root. Docker Desktop on macOS hides that; on a Linux host add --user "$(id -u):$(id -g)", or the agent leaves root-owned files that host-side commits and checkpoints trip over.
  • Bind mounts are slower. Especially on macOS, and especially for the copy-on-write-cloned node_modules or target in each worktree.
  • Editor state lives in the container. code-server’s extensions and settings go with docker rm.

All four examples report the same thing to spwn: an exec prefix. They differ in what they build behind it. Pick the column that matches where spwn itself runs; they’re alternatives, not layers.

spwn on your machine (Docker) spwn in a cluster (Kubernetes)
One environment per session docker-env k8s-env
Plus a shared database dev-env-services k8s-dev-env-services

Builds on docker-env with Postgres, and the interesting part is what’s shared. Each session gets its own app container, as above. The database server (spwn-shared-db, on a spwn-shared network) is started once and shared, because one Postgres per session is minutes of startup and gigabytes of memory for nothing. Each session gets its own database inside it, session_<id>, passed to the container as DATABASE_URL. The app container publishes port 3000 on a free host port Docker picks, and the hook prints the URL and writes it to .spwn/run/preview.url.

Teardown drops the session’s database and removes its container, but leaves the shared server alone, since other sessions are still using it. You remove that by hand when you’re done. The database password is a hardcoded dev, fine for a private local network only.

The Kubernetes counterpart of docker-env, for spwn installed with the Helm chart and sessionPods.enabled=true. The hook creates a pod per session and reports kubectl exec -it -n <ns> -c session <pod> --. It reads everything off spwn’s own pod: the namespace, the node, and the claim behind spwn’s home volume. The pod:

  • mounts that volume at /home/spwn, so every path matches spwn’s;
  • is pinned to spwn’s node, because the volume is ReadWriteOnce;
  • stays in spwn’s namespace, because a claim can’t be mounted from another one;
  • bakes the worktree in as workingDir, because kubectl exec has no -w.

The image must run as uid 1000 and keep the agent CLI outside $HOME. The cluster pulls the image, so push it somewhere it can reach. The hook waits up to 120 seconds for the pod to be ready, then gives up and leaves the session in spwn’s pod. Because the home volume is shared, git credentials and ~/.claude work inside without extra steps.

dev-env-services for Kubernetes. A per-session app pod and Service, one shared Postgres pod and Service pinned to spwn’s node, and a database per session. It needs RBAC beyond the chart’s default (rbac-extra.yaml adds services, persistentvolumeclaims and optionally ingresses). To reach a session’s app, either set SPWN_PREVIEW_DOMAIN for a per-session Ingress hostname, or use the kubectl port-forward command the hook prints. Teardown removes the session’s pod, Service, Ingress and database, and leaves the shared server alone.