Talking back to spwn
A hook isn’t limited to running quietly. It has two ways to talk to spwn:
- Report a value by printing a
::spwn:set:: key=valueline. This is how a hook tells spwn which worktree it made, or which container the session should run in. - Ask the human with
spwn prompt. The script blocks while spwn shows a picker in the UI, then gets the answer on stdout.
Reporting values: ::spwn:set::
Section titled “Reporting values: ::spwn:set::”Print one line per value, on stdout or stderr:
echo "::spwn:set:: exec=docker exec -it -w $SPWN_WORKTREE spwn-$SPWN_TERMINAL_ID"echo "::spwn:set:: execShell=/bin/bash"The rules:
- A line counts if it starts with
::spwn:set::, after any leading whitespace. - Everything after the sentinel is split at the first
=. The key and the value are trimmed, and the value can contain spaces and further=signs. - One key per line. If several scripts report the same key, the last one wins.
- These lines are taken out of the output stream: they never appear in the Hooks panel.
- An unknown key is ignored.
spwn acts on these keys when session-created runs. The environment keys are also picked
up when you re-run an event by hand from the Hooks panel, which is how you rebuild a lost
container. Printed from any other event, they’re dropped silently.
The worktree: worktree, branch, base
Section titled “The worktree: worktree, branch, base”These three tell spwn where the session lives. spwn’s default
~/.spwn/hooks/session-created.d/10-worktree.sh prints all three after creating the
worktree:
echo "::spwn:set:: worktree=$SPWN_WORKTREE"echo "::spwn:set:: branch=$SPWN_BRANCH"echo "::spwn:set:: base=$SPWN_BASE_BRANCH"| Key | Meaning | If you don’t report it |
|---|---|---|
worktree |
Absolute path of the worktree the hook created. Must exist, or it’s ignored. | spwn uses the path it proposed in SPWN_WORKTREE, if something created it there. If neither exists, the session runs in the project folder with no worktree. |
branch |
The session’s branch. | SPWN_BRANCH, the spwn/<id> name spwn proposed. |
base |
The branch this session merges back into. | SPWN_BASE_BRANCH. |
spwn honors these three only from the global session-created scripts, the ones that
run in the project folder before the worktree exists. By the time the repo scripts run, the
session’s location is settled.
This is what lets you replace worktree creation entirely. Delete 10-worktree.sh, write
your own script that makes the checkout wherever and however you like (a different
branch-naming scheme, a sparse checkout), and report the result.
The environment: exec, execHeadless, execBin, execShell
Section titled “The environment: exec, execHeadless, execBin, execShell”These tell spwn to run the session’s processes somewhere other than your machine: in a container, a pod, a VM or over SSH. The hook stands up the environment; spwn only needs to know how to get in. Per-session dev environments walks through a complete example.
| Key | Meaning | Default |
|---|---|---|
exec |
A command prefix spwn puts in front of every interactive process it starts for the session: the agent’s TUI, shells you open on it, and the browser editor. Required: without exec, spwn ignores the other three keys. |
None: processes run on the host. |
execHeadless |
The prefix for scheduled (headless) runs of the session. | None: scheduled runs stay on the host. |
execBin |
The agent’s binary inside the environment. | The agent definition’s bare binary name (e.g. claude), found on the environment’s own PATH. |
execShell |
The shell for shell panes inside the environment, run without -l. |
/bin/sh |
A typical Docker report:
echo "::spwn:set:: exec=/usr/local/bin/docker exec -it -w $SPWN_WORKTREE $name"echo "::spwn:set:: execHeadless=/usr/local/bin/docker exec -i -w $SPWN_WORKTREE $name"echo "::spwn:set:: execShell=/bin/bash"What spwn does with it:
- It splits the prefix into words the way a shell would (quotes are honored) and never looks further. It doesn’t know or care whether it names Docker.
- It launches
<prefix> env KEY=VAL … <command>, so the session’s environment variables cross the boundary as arguments. - It stores the environment on the session, so it survives a restart of spwn, and passes it
to later hooks as
SPWN_EXEC, so asession-deletedscript can find what to tear down. - If the prefix can’t be parsed, spwn shows an error and ignores it. If its first word
isn’t on the
PATHspwn launches panes with, spwn warns you when a pane opens.
Two things to get right:
execmust allocate a terminal. For Docker that’s-it. Without a tty the agent’s TUI renders nothing and spwn can’t tell what state it’s in.execHeadlessmust not. Scheduled runs read the agent’s output as line-delimited JSON, and a tty mixes spinner output into it. For Docker that’s-ialone.
Use an absolute path for the command ($(command -v docker)). Panes are launched by spwn’s
long-lived terminal daemon, whose PATH isn’t necessarily the one your hook sees.
Both scopes of session-created can report an environment. The global scripts report
first; if the repo scripts also report exec, theirs replaces it. A repo hook is the
usual place, because an environment that bind-mounts the worktree needs the worktree to
exist.
Asking the human: spwn prompt
Section titled “Asking the human: spwn prompt”spwn prompt puts a question in front of the user and blocks until they answer. The
chosen label comes back on stdout.
spwn prompt [--multi] [--header TEXT] "Question?" [option ...]spwn sets SPWN_BIN to its own binary, so "$SPWN_BIN" prompt … works even when spwn
isn’t on the hook’s PATH.
Confirm
Section titled “Confirm”With no options, you get Yes / No:
if [ "$("$SPWN_BIN" prompt 'Seed the database for this session?')" = Yes ]; then ./scripts/seed.shfiPick one
Section titled “Pick one”List the options after the question. The label the user clicks is printed:
if profile="$("$SPWN_BIN" prompt --header env 'Which services should start?' none web web+worker)"; then echo "starting: $profile"else echo "no answer; starting nothing"fiPick several
Section titled “Pick several”--multi lets the user choose any number of options (at least one) and press
Send answer. The labels come back on one line, joined by , :
picked="$("$SPWN_BIN" prompt --multi 'Which fixtures?' users orders inventory)" || picked=""case "$picked" in *orders*) ./scripts/load-orders.sh ;; esacOptions
Section titled “Options”| Option | Meaning |
|---|---|
--multi |
Allow several choices. |
--header TEXT or --header=TEXT |
A short tag shown next to the question, e.g. setup. |
The first argument that isn’t an option is the question; the rest are the choices.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
Answered. The label (or labels) is on stdout. |
2 |
Declined: nobody answered. |
3 |
Usage error (no question), or not running inside a spwn hook. |
The exit code never says which option was chosen, so answer=$(…) is safe under
set -e when the user answers. Branch on the string. Wrap the call in if (or add
|| …) so a decline doesn’t end the script.
Who answers, and when it gives up
Section titled “Who answers, and when it gives up”| Where the hook is running | Who answers |
|---|---|
| A session you started | You, in a picker in the spwn UI. After 5 minutes with no answer, it declines. |
| A session a workflow owns | The workflow’s onHookPrompt handler. With no handler, it declines. |
session-created for a scheduled run, a workflow-* event, or a verify started by the land queue or a workflow |
Nobody. It declines at once. |
So always handle the declined branch with a sensible default. A hook that can only proceed with an answer will run unattended one day.
spwn prompt works on macOS and Linux. It talks to spwn over a unix socket named in
SPWN_PROMPT_SOCK, not over the hook’s stdin or stdout, so it doesn’t interfere with the
script’s output.
See also
Section titled “See also”- Hooks reference for the keys and exit codes in one table.
- Cookbook: prompt before setup for a complete script.