How hooks work
Every spwn session lives on its own branch in its own worktree, which makes it the natural place to hang per-session setup and teardown: install dependencies, copy secrets, seed a database, start a container, then clean it all up when the session goes.
spwn does this the unix way. You write one shell script per lifecycle event, and spwn
runs it with the session’s details in SPWN_* environment variables. spwn has no opinion
about what the script does. Plain shell, Docker, kubectl, a Python script with a shebang:
spwn just runs it.
npm install --prefer-offlinecp "$SPWN_PROJECT_DIR/.env" "$SPWN_WORKTREE/.env"echo "ready on $SPWN_BRANCH"spwn runs on its own hooks, too. Creating the worktree, committing each turn and taking
checkpoints aren’t hardcoded. They ship as default scripts in
~/.spwn/hooks that you can read, extend, reorder or delete.
Your first hook
Section titled “Your first hook”Add a script to your repo, commit it, and start a session:
mkdir -p .spwn/hooks/session-created.dcat > .spwn/hooks/session-created.d/20-hello.sh <<'EOF'#!/bin/shecho "session $SPWN_TERMINAL_ID on $SPWN_BRANCH (from $SPWN_BASE_BRANCH)"echo "worktree: $SPWN_WORKTREE"EOFchmod +x .spwn/hooks/session-created.d/20-hello.shgit add .spwn/hooks && git commit -m "Add a spwn hook"Open a new session, then open the Hooks tab in the session’s inspector. session-created
has a green dot. Click Output to see what the script printed.
Events
Section titled “Events”| Event | When it fires | Runs in |
|---|---|---|
session-created |
A new agent session is being set up. The global scripts create the worktree; the repo scripts run once it exists. The session waits for both. | Global: the project dir. Repo: the new worktree. |
session-ready |
Once, the first time spwn learns the agent’s session id (shortly after the agent starts). The first point where SPWN_SESSION_ID is set. |
The worktree |
session-turn |
After every completed agent turn. The defaults commit the turn and take a checkpoint. | The worktree |
session-integrate |
When you (or the land queue, or a workflow) verify a session: spwn builds a throwaway checkout of the session merged into its base, and runs these scripts there. See Sync, verify, merge. | The trial checkout |
session-deleted |
When you delete a session, before its worktree is removed. | Repo: the worktree. Global: the project dir. |
workflow-started |
A workflow run starts. | The project dir |
workflow-stopped |
A workflow run ends. SPWN_WORKFLOW_STATUS is ok, error or stopped. |
The project dir |
The five session-* events fire only for sessions that have their own worktree and branch,
which means a git project with worktree creation turned on. A session in a non-git folder
gets no session hooks.
The Hooks reference lists every variable each event receives.
Where hooks live
Section titled “Where hooks live”Hooks come from two places, and both run:
| Scope | Folder | Applies to |
|---|---|---|
| Global | ~/.spwn/hooks/ |
Every session in every project on this machine. |
| Repo | <repo>/.spwn/hooks/ |
Sessions of that repo. Committed with the code, so it travels with every checkout. |
For most events, global runs first, then repo. session-deleted is the exception: the
repo scripts run first, inside the worktree, so your teardown can still read it. Then the
global scripts remove it.
A single script, or a folder of steps
Section titled “A single script, or a folder of steps”Within a scope, an event can be a single <event>.sh, a <event>.d/ folder, or both. spwn
runs the bare <event>.sh first, then every script in <event>.d/ sorted by filename.
Number your files to order them:
~/.spwn/hooks/ session-created.d/ 10-worktree.sh # spwn's default: creates the worktree 50-my-setup.sh # yours: runs after it, in every project<repo>/.spwn/hooks/ session-created.sh # runs first in the repo scope session-created.d/ 20-container.sh 30-seed.shSo the full order for one event is:
~/.spwn/hooks/<event>.sh~/.spwn/hooks/<event>.d/* (sorted)<repo>/.spwn/hooks/<event>.sh<repo>/.spwn/hooks/<event>.d/* (sorted)(For session-deleted, the two repo lines come first.)
A folder lets you add or remove a step by dropping in or deleting a file, without editing anyone else’s script. It’s also how you add to spwn’s defaults without touching them.
How a script is run
Section titled “How a script is run”- Executable (
chmod +x): run directly, so its shebang applies.20-setup.pywith#!/usr/bin/env python3works. - Not executable: run with
sh <file>. - Inside
<event>.d/, spwn only runs files that are executable or end in.sh. Hidden files and anything else (aREADME,notes.txt) are ignored, so helpers can live there.
A script’s stdin is /dev/null, so a stray read gets end-of-file instead of hanging. To
ask the user something, use spwn prompt.
Hooks are synchronous
Section titled “Hooks are synchronous”The session waits for each script to finish, one after another. There is no timeout: a script that hangs holds up the session. Start anything long-running in the background yourself:
PORT=3000 npm run dev > .spwn/run/dev.log 2>&1 & disownFailures are advisory
Section titled “Failures are advisory”A script that exits non-zero gets a red dot in the Hooks panel and a one-line toast (“Hook failed on session-created: 20-setup.sh”). It never blocks or fails the session, and the next script still runs.
The one failure with real consequences is spwn’s own 10-worktree.sh. If it fails, or you
delete it, and nothing reports a worktree, the session runs in the project folder with no
branch of its own.
Output and talking back
Section titled “Output and talking back”Everything a script prints to stdout or stderr streams live into the Hooks panel. spwn keeps the last 8 KB of each script’s output.
A line of the form ::spwn:set:: key=value is not output. It’s a message to spwn, and it
never shows in the panel. That’s how the default hook tells spwn which worktree it made, and
how a hook hands spwn a container to run the session in. See
Talking back to spwn.
The Hooks panel
Section titled “The Hooks panel”Each session’s inspector has a Hooks tab. It lists all five session events, and for each one:
- A status dot: green if every script passed on its last run, red if any failed, a spinner while running, grey if it hasn’t run yet.
- The scripts spwn found, labelled by scope, e.g.
global: 10-worktree.sh, repo: 20-setup.sh. - Output: each script’s last output, headed by its scope and exit code. While a hook runs, the log opens by itself and shows lines as they arrive.
- Run: fire the event again by hand, both scopes, each in its usual folder.
Run is how you rebuild a session’s environment after its container was removed: re-run
session-created, and spwn records the fresh environment it reports. Write your
session-created scripts to be safe to run twice. The defaults are.
Edits apply immediately
Section titled “Edits apply immediately”spwn reads hook scripts from disk each time an event fires, so there’s nothing to reload
or restart. Save a script and the next event uses it. spwn also watches ~/.spwn/hooks and
each project’s .spwn/hooks, so the Hooks panel refreshes when you add or remove a script.
The rest of .spwn works the same way: workflows/, schedules/ and agents/ are
watched too.
Remember that a session’s repo hooks come from its own worktree. Editing
.spwn/hooks in your main checkout affects sessions created after you commit, not the
ones already running. Editing it inside a session’s worktree affects that session right away.
The default global hooks
Section titled “The default global hooks”On startup, spwn installs its own per-session behavior as ordinary scripts in
~/.spwn/hooks. The source is in
backend/assets/hooks.
| Script | What it does |
|---|---|
session-created.d/10-worktree.sh |
Runs in the project dir. Creates the worktree on a new spwn/<id> branch (starting at SPWN_START_POINT for a fork from an earlier turn), turns on git rerere if you haven’t set it, copy-on-write clones heavy gitignored folders (node_modules, target, .venv, venv, dist, build, .next, .svelte-kit, .turbo) into it, and reports the worktree, branch and base back to spwn. |
session-turn.d/10-commit.sh |
Commits the turn’s changes (git add -A) onto the session branch as “spwn session”, with --no-verify. |
session-turn.d/20-checkpoint.sh |
Runs "$SPWN_BIN" checkpoint "$SPWN_TURN_UUID" to snapshot the worktree for the Timeline. |
session-deleted.d/90-worktree.sh |
Runs in the project dir, after the repo scripts. Force-removes the worktree, then deletes the branch. |
To add a step, drop your own numbered file next to them: 50-my-setup.sh in
session-created.d/ runs after the worktree exists; 10-my-teardown.sh in
session-deleted.d/ runs before it’s removed.
To opt out of one behavior, delete its script. Delete 10-commit.sh and turns stop
auto-committing.
spwn owns these four files. When you upgrade spwn, it rewrites any of them that still exist, so edits you make to them are lost. It never recreates one you deleted, and never touches files you added. Put your changes in your own files.
Run shared global hooks
Section titled “Run shared global hooks”Settings → Hooks → Run shared global hooks turns the whole ~/.spwn/hooks folder on or
off. It’s on by default.
Turning it off switches off spwn’s defaults too, and worktree creation lives in them. New sessions then run in the project folder with no worktree or branch of their own, so they get no per-session hooks at all; deleting a session no longer removes its worktree; and turns are no longer committed or checkpointed. Leave it on unless that’s what you want.
Where to next
Section titled “Where to next”- Talking back to spwn: report values with
::spwn:set::and ask the user withspwn prompt. - Per-session dev environments: run each session inside its own container or pod.
- Cookbook: copy-paste recipes.
- Hooks reference: every event, variable and rule in one place.