Skip to content

Agent playbook ​

Recipes for a coding agent working in a repo that uses stackctl. Each one links to the reference page with the details.

Rules ​

  • Run stackctl through the package manager: pnpm stackctl … (or npx, yarn, bunx). It is a dev dependency, not on your PATH. The recipes here write stackctl for short.
  • Never kill, pkill or lsof … | kill a stackctl process or its port. Stop a session with POST /command/quit or stackctl stop, and restart a service with stackctl service restart -s <name>. A killed session leaves its services running.
  • Check before you start anything. Starting a session in a mode quits the one already running in it. stackctl refuses that when a person started it.
  • Never guess a port or hostname. stackctl show-config prints the resolved ones.
  • Don't pass --kill-others or killOthers: true unless the user said to.
  • Don't use stackctl start --detach. It is for CI and is refused for an agent.
  • stackctl infra reset and stackctl infra destroy delete data. Only on request.
  • Use stackctl infra docker-compose …, never a bare docker compose …, which talks to a different Compose project.
  • Write stackctl, never its alias sctl. Guide pages type sctl; permission rules and scripts match the full name.

Is it running? ​

  1. Is the repo managed by stackctl? Look for stackctl.config.ts at or above the app's directory. If there is none, see Apps stackctl doesn't manage.
  2. Is the app one of its services? stackctl show-config | jq '.services | keys'. The key is the name -s and the control API take. It need not match the directory.
  3. Ask stackctl: stackctl status says whether the mode's session runs, each service's state and readiness, and the last commands that acted on it. stackctl status --json has the same for jq: .status.services, .status.ready. See stackctl status.
services.<name>Meaning
runningIts process is live. ready.<name> says whether it passes its healthcheck.
waitingHeld back by a service it waits for.
restartingOn its way back up.
stoppedNot running: it exited, crashed or was never started.
not a keyConfigured, but not part of this session.

No services yet means the session is still starting its infra. Check again in a few seconds.

Reach a service ​

bash
stackctl show-config | jq '.services | map_values({ port, publicOrigin, healthcheck })'

No session is needed. Pass the --mode the session runs in.

  • publicOrigin is where a browser reaches the service, and the address the app itself is configured for. Through the reverse proxy, a service that is down answers 503 with its status, not a connection error.
  • port is the service itself: curl http://localhost:<port>/…. It works in every setup, while the service runs.
  • *.localhost names resolve in browsers, not in curl. Pass the host: curl -H 'Host: api.localhost' http://127.0.0.1:<proxy port>/.
  • Infra services with a port are under .infra["docker-compose"].services.

Start a stack ​

Only when no session is running in that mode. There is no TTY, so always pass --headless and name the services:

bash
stackctl --headless -s web
  1. Run it with your harness's background-task tool, so the session ends when the task or your conversation does. Never with &, nohup or setsid.
  2. Find its port in node_modules/.stackctl/modes/<mode>/running.json, which appears as soon as the session starts.
  3. Wait until it is healthy on the event stream:
bash
curl -sN "http://127.0.0.1:$port/events" \
  | grep -m1 -E '"sessionReady":true|"type":"session-state","ready":true|"status":"stopped"'

A line with "status":"stopped" means a service crashed while starting. Read the log. 4. Stop it when done: POST /command/quit, or stop the background task.

The config's infra comes up first on its own, and stays up after the session ends. For an e2e suite, call startStack() from its global setup instead. For a CI workflow you are asked to write, use stackctl start --detach.

Run tests ​

Tests that call the services need them running, in the test mode: its own ports and its own database, so a test never writes to the data you develop with.

  1. Find the test mode: a .env.test beside stackctl.config.ts. Without one, ask.
  2. Start a test session of the services the tests call, as in Start a stack: stackctl --mode test --headless -s api. It runs beside a development session when the services it starts have ports of their own in the test mode.
  3. Run the tests in its environment: stackctl --mode test run api test.
  4. Stop it: POST /command/quit to the port in node_modules/.stackctl/modes/test/running.json.

A suite that calls startStack() starts its own session: just run it. Never run tests against the development session.

Drive a running session ​

From a shell, in the session's mode:

bash
stackctl service restart -s api   # waits until it is ready again
stackctl service stop -s worker
stackctl service start -s worker  # also adds a service the session was started without

Or over the control API: POST /command/service/<start|stop|restart>/<name>.

stackctl stop ends the whole session, including one a person started. Run it only when asked.

A service is down ​

  1. Read its state, as in Is it running?: stopped, or running but not ready. A dev script that watches its files, such as node --watch, outlives its app: when the app exits it stays running and waits for an edit.
  2. Find why it stopped: stackctl logs | grep '\[api *\]' | grep -iE 'error|exception'. A crash prints its error first, then its stack and anything it dumps, so its last lines rarely say why.
  3. Find what acted on it. The event log records every command that changed the stack, by a key, the control API or a stackctl command, and who ran it. stackctl status lists the last ones; stackctl events -n 50 has them all, with each service's state changes: an infra service restarted just before the crash is the likely cause.
  4. Restart it: stackctl service restart -s api waits until it is ready again.
  5. Read its lines again, to see it come up or why it failed again.

Don't run the service's command yourself while the session runs it: your copy takes its port.

After editing code ​

Read the service's command in stackctl show-config:

command.watchesAfter an edit to its code
trueNothing. Its dev script reloads itself.
falsestackctl service restart -s <name>

An edit to a .env file restarts the affected services on its own, so an unexpected restart may be an env edit, not a crash. An edit to stackctl.config.ts needs the session restarted.

Run a one-off command ​

Use the service's own environment. Don't export variables by hand.

bash
stackctl run api seed                          # a package.json script or Makefile target
stackctl exec api -- node src/migrate.ts       # any command
stackctl --mode test exec api -- node --test   # in the test mode's environment

See One-off commands. From a script, use exec(), run() or resolveEnv().

Infra ​

bash
stackctl infra start                       # start the databases etc., wait until ready
stackctl infra docker-compose ps           # what is running
stackctl infra docker-compose exec db psql -U app
stackctl infra docker-compose logs --tail 100 db

A session starts its infra itself. In a running session, restart one infra service with POST /command/infra/restart/<name>. See Infra.

Read logs ​

bash
stackctl logs                 # the current session's whole log
stackctl logs | grep -i error
stackctl logs --path          # its path, to read or tail

Pipe stackctl logs rather than substituting $(stackctl logs --path): a harness can't check a command substitution before it runs, so it asks the user.

Each line is <timestamp> [<service>] <text>, the label padded with spaces before its ] so every line's text lines up: match one service's with grep '\[api *\]'. [stackctl] lines are the session's own, [control] lines are commands received over the control API, and [infra:<name>] lines are an infra service's. Earlier sessions' logs are kept beside the current one. See Logs.

Onboard a service ​

To add a service to stackctl:

  1. Give it a dev entry point. A dev script in its package.json, or for a service without one, a Makefile target (recipe lines indented with a tab). If it already has an equivalent script under another name (start:dev, serve), rename it to dev and fix the references, or set command.
  2. Add a key to services in stackctl.config.ts. The key is its name, and its directory is <serviceRootDir>/<key>:
ts
'my-service': {
  port: 3001,
  healthcheck: '/health',
  deps: [{ service: 'other-service', wait: true }],
},
  1. Fill in what is true of it, per Services:
    • port: the port it listens on. It reads it from PORT.
    • healthcheck: a path, if it has a health endpoint.
    • deps: the services it needs, with wait: true for one it can't start without.
    • command: ['<script>', { watches: false }] if its dev script doesn't reload on edits.
    • urls: [''] for something a person opens in a browser.
  2. Prefer paths to full URLs ('/health', '/admin'), and a callback only for a value that depends on the environment. Config strings are not interpolated.
  3. Check it: stackctl show-config | jq '.services["my-service"]'.

Apps stackctl doesn't manage ​

If the repo has no stackctl config, or the app isn't in services, say so. That is a complete answer. Then check the app directly: find the port it binds, and

bash
lsof -nP -iTCP:$PORT -sTCP:LISTEN
curl -sS -o /dev/null -w '%{http_code}\n' --max-time 2 "http://localhost:$PORT/"

A service stackctl manages can also be running outside it, started by hand. /query/status can't see that, the port check can. Report what you find, and never kill it.

Released under the MIT License.