Hooks Cookbook
Practical, copy-paste recipes for project hooks. Each one is a generic template — safe no-op defaults with commented placeholders you swap for your stack. If you haven’t set up a hook before, start with the Project Hooks guide for the mechanics.
The single-script recipes live, runnable, in
examples/hooks/cookbook/.
The two container recipes need more than one file — an image, a create hook and a
teardown hook — so each has its own folder:
examples/hooks/docker-env/
and
examples/hooks/dev-env-services/.
Recipes at a glance
Section titled “Recipes at a glance”| Recipe | Event | What it does |
|---|---|---|
| Pull the base branch | session-created |
Fast-forward the parent repo’s base branch so the session starts current. |
| Copy gitignored secrets | session-created |
Copy .env, certs, and other untracked config into the fresh worktree. |
| Per-session preview env | session-created |
Install deps and start a dev server on a per-session port, backgrounded. |
| Seed a database | session-created |
Migrate + seed a per-session database, idempotently. |
| Prompt before setup | session-created |
Ask before expensive setup with spwn prompt, gating what runs. |
| Tear down on delete | session-deleted |
Stop the preview server and drop ephemeral data before the worktree is removed. |
| Per-session container | session-created |
Run the agent itself inside its own Docker container. |
| Dev env with services | session-created |
The above plus a database: per-session app, one shared server, isolated data. |
Pull the base branch
Section titled “Pull the base branch”When a new top-level session starts, fast-forward the parent repo’s base branch so the
worktree is cut from up-to-date code. Skips child/fork (spwn/…) sessions, which inherit
their parent’s tree as-is.
#!/usr/bin/env bashset -euo pipefail
base="${SPWN_BASE_BRANCH:-}"
# Child/fork sessions branch off a parent session's `spwn/…` branch, not a real# branch. They should inherit the parent's tree as-is, so skip the pull for them.# (`cm/*` is the legacy prefix — matched too so older sessions keep working.)case "$base" in spwn/*|cm/*|"") echo "[$SPWN_EVENT] base is '$base' (child/unknown session); skipping base-branch pull" exit 0 ;;esac
# Worktrees live off SPWN_PROJECT_DIR (the shared parent checkout). Pull there so every# new top-level session branches off an up-to-date base.cd "$SPWN_PROJECT_DIR"echo "[$SPWN_EVENT] refreshing base branch '$base' in $SPWN_PROJECT_DIR"
git fetch origin "$base"
current_branch="$(git rev-parse --abbrev-ref HEAD)"if [ "$current_branch" = "$base" ]; then # On the base branch: fast-forward only so we never create a merge commit or conflict. git merge --ff-only "origin/$base"else # Parent checked out elsewhere: update the local base ref to match remote without # switching branches. No-op if it can't fast-forward. git fetch origin "$base:$base" \ || echo "[$SPWN_EVENT] could not fast-forward local '$base'; skipping"fiCopy gitignored secrets
Section titled “Copy gitignored secrets”A worktree is a fresh checkout, so files that aren’t committed (.env, local certs,
service-account JSON) are not present. Copy them from the shared project dir.
#!/usr/bin/env bashset -euo pipefail
# Files to bring across (gitignored, so absent from the checkout). Adjust to taste.files=(".env" ".env.local" ".envrc")
for f in "${files[@]}"; do src="$SPWN_PROJECT_DIR/$f" dst="$SPWN_WORKTREE/$f"
if [ -e "$dst" ]; then echo "[$SPWN_EVENT] $f already present; leaving as-is" continue fi if [ -e "$src" ]; then cp "$src" "$dst" echo "[$SPWN_EVENT] copied $f from project dir" else echo "[$SPWN_EVENT] no $f in project dir; skipping" fidonePer-session preview environment
Section titled “Per-session preview environment”Install deps and start a dev server on a per-session port derived from
SPWN_TERMINAL_ID, so parallel sessions don’t collide. Because hooks are
synchronous with no timeout, the server is
backgrounded (& disown) and its PID/URL written under .spwn/run/ for
teardown to consume.
#!/usr/bin/env bashset -euo pipefail
run_dir="$SPWN_WORKTREE/.spwn/run"mkdir -p "$run_dir"
# Derive a deterministic port in 3000–3999 from the terminal id (no RNG, so it's stable# across re-runs and unique per session).hash="$(printf '%s' "$SPWN_TERMINAL_ID" | cksum | cut -d' ' -f1)"port=$(( 3000 + (hash % 1000) ))echo "[$SPWN_EVENT] preview port for $SPWN_TERMINAL_ID = $port"
# --- Install dependencies ---# Heavy gitignored dirs (e.g. node_modules) may already be COW-seeded into the worktree,# so guard the install to avoid redundant work.if [ ! -d node_modules ]; then echo "[$SPWN_EVENT] installing dependencies…" # npm ci # bundle install # uv sync :fi
# --- Start the dev server in the background ---# Keep the `& disown` so the session doesn't block, and redirect output to a log the# agent can tail.echo "[$SPWN_EVENT] starting preview server on :$port"PORT="$port" npm run dev >"$run_dir/preview.log" 2>&1 & disownecho $! > "$run_dir/preview.pid"
# Record the URL so tooling / notifications / the agent can find it.printf 'http://localhost:%s\n' "$port" > "$run_dir/preview.url"echo "[$SPWN_EVENT] preview -> $(cat "$run_dir/preview.url") (pid $(cat "$run_dir/preview.pid"))"Seed a database
Section titled “Seed a database”Run migrations and load fixtures against a database namespaced per session, so parallel sessions don’t clobber each other. Keep it idempotent — the Hooks panel’s Run button lets you re-fire it by hand.
#!/usr/bin/env bashset -euo pipefail
# Namespace the database per session so parallel sessions don't clobber each other.db="session_${SPWN_TERMINAL_ID}"echo "[$SPWN_EVENT] preparing database '$db'"
# --- Create (idempotent) ---createdb "$db" 2>/dev/null || true
# --- Migrate ---DATABASE_URL="postgres://localhost/$db" npm run migrate# php artisan migrate --force# rails db:migrate
# --- Seed / load fixtures ---DATABASE_URL="postgres://localhost/$db" npm run seed# python manage.py loaddata fixtures/dev.json
echo "[$SPWN_EVENT] database ready"Prompt before setup
Section titled “Prompt before setup”Gate expensive or optional setup on your answer. spwn prompt
shows a picker in the app and blocks until you answer, printing the chosen label to stdout.
#!/usr/bin/env bashset -euo pipefail
# --- Yes/No confirm (no options ⇒ Yes/No) ---if [ "$("$SPWN_BIN" prompt --header setup 'Seed the database for this session?')" = Yes ]; then echo "[$SPWN_EVENT] seeding…" ./.spwn/hooks/setup/seed-db.shelse echo "[$SPWN_EVENT] skipping seed"fi
# --- Multiple choice ---# Give explicit options; the chosen label comes back on stdout. `--multi` allows several# (returned comma-joined). Wrap in `if` to catch a decline / headless run.if profile="$("$SPWN_BIN" prompt --header env 'Which services to start?' none web 'web+worker')"; then echo "[$SPWN_EVENT] starting profile: $profile" case "$profile" in web) npm run dev & disown ;; web+worker) npm run dev & disown; npm run worker & disown ;; none) : ;; esacelse echo "[$SPWN_EVENT] no selection (declined/headless); starting nothing"fiTear down on delete
Section titled “Tear down on delete”Undo whatever session-created started. This runs synchronously just before the
worktree is removed, so it can still read the worktree’s files. Guard every step
(|| true) and keep it idempotent — a failing teardown must never block the delete.
#!/usr/bin/env bashset -euo pipefail
run_dir="$SPWN_WORKTREE/.spwn/run"
echo "[$SPWN_EVENT] tearing down session $SPWN_TERMINAL_ID (${SPWN_BRANCH:-<none>})"
# --- Stop the preview server started by the preview-env recipe ---if [ -f "$run_dir/preview.pid" ]; then pid="$(cat "$run_dir/preview.pid" 2>/dev/null || true)" if [ -n "${pid:-}" ]; then echo "[$SPWN_EVENT] stopping preview server (pid $pid)" kill "$pid" 2>/dev/null || true fifi
# --- Bring down any ephemeral containers (namespace by terminal id) ---# docker compose -p "$SPWN_TERMINAL_ID" down -v || true
# --- Drop ephemeral data / release resources ---# dropdb "session_${SPWN_TERMINAL_ID}" 2>/dev/null || truerm -rf "$run_dir" 2>/dev/null || true
echo "[$SPWN_EVENT] teardown complete"Per-session container environment
Section titled “Per-session container environment”The recipes above run things alongside a session. This one runs the session inside a container: the agent’s TUI and any shell you open on it execute there, so its builds, tests and installs never touch your machine — and two sessions can want two different toolchains without a fight.
The mechanism is one reported value. spwn prepends the prefix to every pane’s argv; it has no Docker code of its own. See Running a session somewhere else.
#!/usr/bin/env bashset -euo pipefail
name="spwn-$SPWN_TERMINAL_ID"image="${SPWN_ENV_IMAGE:-spwn-session-env}"
# Absolute path: spwn launches panes against the rmux daemon's PATH, not this script's.docker="$(command -v docker)" || { echo "no docker; staying on the host"; exit 0; }
# Don't pull here — hooks are synchronous, so a cold pull looks like a hung session."$docker" image inspect "$image" >/dev/null 2>&1 || { echo "build $image first"; exit 0; }
# Mount at the SAME absolute path inside and out. spwn finds the transcript by a slug# of the cwd, and a worktree's .git holds an absolute pointer into the main repo —# a different path breaks the Timeline, rewind and git, silently.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 || true
# -it for the TUI (it needs a tty to render); -i only for headless JSON parsing.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"Pair it with teardown, numbered to run before spwn’s 90-worktree.sh:
#!/usr/bin/env bashset -euo pipefaildocker="$(command -v docker)" || exit 0# rm -f, not stop: --restart unless-stopped would resurrect a stopped container."$docker" rm -f "spwn-$SPWN_TERMINAL_ID" >/dev/null 2>&1 || truePer-session dev environment with services
Section titled “Per-session dev environment with services”A real dev environment is rarely one container. This extends the previous recipe with a database, and the interesting part is what’s shared and what isn’t:
| Scope | Why | |
|---|---|---|
| App container | Per session | It’s what the agent runs in; it holds the worktree. |
| Database server | Shared | One postgres per session is minutes of startup and gigabytes of RAM for nothing. |
| Database contents | Per session | Sessions must not clobber each other’s data. |
So: one server process, a separate database inside it per session. That middle ground is what makes running ten sessions at once practical.
#!/usr/bin/env bashset -euo pipefail
net="spwn-shared"db="spwn-shared-db"app="spwn-$SPWN_TERMINAL_ID"image="${SPWN_ENV_IMAGE:-spwn-session-env}"docker="$(command -v docker)" || { echo "[$SPWN_EVENT] no docker; staying on host"; exit 0; }
# --- Shared, created once, reused by every session ---------------------------------"$docker" network inspect "$net" >/dev/null 2>&1 || "$docker" network create "$net" >/dev/nullif ! "$docker" inspect "$db" >/dev/null 2>&1; then echo "[$SPWN_EVENT] starting the shared database" "$docker" run -d --name "$db" --network "$net" --restart unless-stopped \ -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev \ -v spwn-shared-db-data:/var/lib/postgresql/data \ postgres:16-alpine >/dev/nullfi"$docker" start "$db" >/dev/null 2>&1 || true
# Postgres accepts connections a beat after the container starts; createdb before that# fails and the session comes up pointing at a database that doesn't exist.for _ in $(seq 30); do "$docker" exec "$db" pg_isready -U dev -q && break sleep 1done
# --- A database per session, inside that shared server -----------------------------# Postgres identifiers are limited and the terminal id has dashes, so sanitise it.dbname="session_$(printf '%s' "$SPWN_TERMINAL_ID" | tr -cd '[:alnum:]' | cut -c1-40)""$docker" exec "$db" createdb -U dev "$dbname" 2>/dev/null \ && echo "[$SPWN_EVENT] created database $dbname" \ || echo "[$SPWN_EVENT] database $dbname already exists"
# --- The per-session app container the agent runs in -------------------------------gitdir="$(git -C "$SPWN_WORKTREE" rev-parse --path-format=absolute --git-common-dir)"
if ! "$docker" inspect "$app" >/dev/null 2>&1; then "$docker" run -d --name "$app" --network "$net" --restart unless-stopped \ -p 127.0.0.1::3000 \ -v "$SPWN_WORKTREE:$SPWN_WORKTREE" \ -v "$gitdir:$gitdir" \ -v "$HOME/.claude:$HOME/.claude" \ -e "HOME=$HOME" \ -e "DATABASE_URL=postgres://dev:dev@$db:5432/$dbname" \ -w "$SPWN_WORKTREE" \ "$image" sleep infinity >/dev/nullfi"$docker" start "$app" >/dev/null 2>&1 || true"$docker" exec "$app" git config --global --add safe.directory "$SPWN_WORKTREE" || true"$docker" exec "$app" git config --global --add safe.directory "$gitdir" || true
# --- Surface the port Docker picked ------------------------------------------------# `-p 127.0.0.1::3000` lets Docker choose a free host port, so N sessions never collide.mkdir -p "$SPWN_WORKTREE/.spwn/run"hostport="$("$docker" port "$app" 3000/tcp | head -1 | sed 's/.*://')"if [ -n "${hostport:-}" ]; then printf 'http://localhost:%s\n' "$hostport" > "$SPWN_WORKTREE/.spwn/run/preview.url" echo "[$SPWN_EVENT] preview -> http://localhost:$hostport"fi
echo "::spwn:set:: exec=$docker exec -it -w $SPWN_WORKTREE $app"echo "::spwn:set:: execHeadless=$docker exec -i -w $SPWN_WORKTREE $app"echo "::spwn:set:: execShell=/bin/bash"Teardown removes what belongs to this session and leaves the shared server alone:
#!/usr/bin/env bashset -euo pipefaildocker="$(command -v docker)" || exit 0
dbname="session_$(printf '%s' "$SPWN_TERMINAL_ID" | tr -cd '[:alnum:]' | cut -c1-40)""$docker" exec spwn-shared-db dropdb -U dev --if-exists "$dbname" 2>/dev/null || true# rm -f, not stop: --restart unless-stopped resurrects a merely-stopped container."$docker" rm -f "spwn-$SPWN_TERMINAL_ID" >/dev/null 2>&1 || trueThe shared database and network are deliberately never removed — a session’s teardown must not take down a server other live sessions are using. Ref-counting that was one of the things that made spwn’s old built-in compose integration too complicated to keep. Clean up by hand when you’re done for the day:
docker rm -f spwn-shared-db && docker network rm spwn-sharedInstall a recipe
Section titled “Install a recipe”Copy a recipe to .spwn/hooks/<event>.sh in your repo, then commit — hooks travel into
each session via the git checkout, so an uncommitted hook never runs.
mkdir -p .spwn/hookscp examples/hooks/cookbook/pull-base-branch.sh .spwn/hooks/session-created.shcp examples/hooks/cookbook/teardown.sh .spwn/hooks/session-deleted.shchmod +x .spwn/hooks/*.shgit add .spwn/hooks && git commit -m "Add spwn hooks"To run several recipes for one event, use a <event>.d/ folder of numbered scripts
instead of a single file — spwn runs .spwn/hooks/<event>.d/* in filename order (see
Where hooks live):
mkdir -p .spwn/hooks/session-created.dcp examples/hooks/cookbook/pull-base-branch.sh .spwn/hooks/session-created.d/10-pull-base.shcp examples/hooks/cookbook/copy-secrets.sh .spwn/hooks/session-created.d/20-secrets.shchmod +x .spwn/hooks/session-created.d/*.shgit add .spwn/hooks && git commit -m "Add spwn hooks"To apply a recipe to every project, drop it in the shared ~/.spwn/hooks/ folder
instead of a repo (no commit needed — it isn’t tied to any repo).
More ideas
Section titled “More ideas”Notifications on session-ready (Slack/desktop, open a draft PR), toolchain pinning
(nvm use, asdf install), tunnels (ngrok/cloudflared) for a shareable preview URL,
salvaging uncommitted work on delete, and deprovisioning cloud previews for the branch.
- Project Hooks — the mechanics, events, and environment.
- Branches & Merging
- Scheduled Tasks