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.
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.
What runs where
Section titled “What runs where”| 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 it1. Install it
Section titled “1. Install it”mkdir -p .spwncp -R examples/hooks/docker-env/.spwn/hooks .spwn/cp -R examples/hooks/docker-env/.spwn/env .spwn/chmod +x .spwn/hooks/*/*.shdocker build -t spwn-session-env .spwn/envgit 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.
2. The image
Section titled “2. The 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 withexecBin. env,gitand a shell, so no distroless orscratchbase.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.
3. Create the container
Section titled “3. Create the container”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:
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 0fiThe 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:
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/nullfi"$docker" start "$name" >/dev/null 2>&1 || true4. Same absolute path, inside and out
Section titled “4. Same absolute path, inside and out”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
.gitis 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.
5. Report the environment
Section titled “5. Report the environment”Finally, the hook tells git the mounted paths are safe and hands spwn the way in:
"$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"execuses-it. The agent’s TUI needs a terminal to render at all, and-tis also what forwards window resizes.execHeadlessuses-ionly. 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.dockeris an absolute path because panes are launched by spwn’s long-lived terminal daemon, whosePATHmay not include what the hook’s does.
6. Tear it down
Section titled “6. Tear it down”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:
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 || truefiIt 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.
Recovery
Section titled “Recovery”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.
Caveats
Section titled “Caveats”- Credentials don’t follow the session in. spwn gives panes your GitHub token as
paths: a git credential helper and a
ghconfig 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, andghfinds 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_modulesortargetin each worktree. - Editor state lives in the container. code-server’s extensions and settings go with
docker rm.
More recipes
Section titled “More recipes”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 |
dev-env-services
Section titled “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.
k8s-env
Section titled “k8s-env”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, becausekubectl exechas 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.
k8s-dev-env-services
Section titled “k8s-dev-env-services”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.