Skip to content

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=value line. 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.

Print one line per value, on stdout or stderr:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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 a session-deleted script 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 PATH spwn launches panes with, spwn warns you when a pane opens.

Two things to get right:

  • exec must 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.
  • execHeadless must not. Scheduled runs read the agent’s output as line-delimited JSON, and a tty mixes spinner output into it. For Docker that’s -i alone.

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.

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.

With no options, you get Yes / No:

Terminal window
if [ "$("$SPWN_BIN" prompt 'Seed the database for this session?')" = Yes ]; then
./scripts/seed.sh
fi

List the options after the question. The label the user clicks is printed:

Terminal window
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"
fi

--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 , :

Terminal window
picked="$("$SPWN_BIN" prompt --multi 'Which fixtures?' users orders inventory)" || picked=""
case "$picked" in *orders*) ./scripts/load-orders.sh ;; esac
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.

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.

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.