| name | add-daemon-command |
| description | Add a daemon lifecycle (`daemon start|stop|status`) to an agent-first CLI, built on a bind-configurable `serve --host --port` command and `GET /_health` / `POST /_shutdown` routes. Use when asked to "add a daemon command", "background this server", "add start/stop/status", or make a CLI that runs an HTTP server manageable without a foreground terminal. |
Add daemon lifecycle to a CLI (cli-daemon-spec convention)
Give a CLI a daemon start|stop|status lifecycle built on serve --host --port
plus GET /_health / POST /_shutdown. Full norms in PROTOCOL.md;
full how-to (machin + Go + the PID-file variant) in RECIPE.md. This
skill is the checklist.
Decide the shape first
- Tool has an embedded HTTP server: implement all of §1-§4 below (the
HTTP-probe mechanism). This is the SHOULD-preferred shape — a health check
that asks "are you healthy and are you actually me" can't be fooled by PID
reuse or a stale file the way a bare PID file can.
- Tool has no HTTP server: skip straight to the PID-file variant
(RECIPE.md Step 4) — fork + PID file +
SIGTERM, same subcommand names,
same idempotency rules.
The pieces
-
<tool> serve [--host HOST] [--port PORT] — foreground, blocks until
stopped. --host defaults to 127.0.0.1, never 0.0.0.0. Print one
confirmation line to stderr once bound ([serve] listening on http://127.0.0.1:8080), flushed immediately.
-
GET /_health — open (no auth), fast, {"ok":true,"service":"myapp","pid":N}
(or 503 {"ok":false,...} if the tool considers itself unhealthy).
-
POST /_shutdown — respond 200 {"ok":true,"stopping":true} then
exit. The moment --host isn't loopback, this MUST require
Authorization: Bearer <token> read from a local file
(~/.<tool>/shutdown.token, 0600, generated fresh per serve start) — an
unauthenticated shutdown route on a 0.0.0.0 bind is a denial-of-service
button, not a convenience. Loopback-bound daemons may skip the token.
-
<tool> daemon start|stop|status [--host H] [--port N] — the lifecycle
wrapper:
start: spawn serve detached (redirect stdio to a log file), poll
/_health (100ms steps, 5s timeout — never a fixed sleep). Already
running → {"ok":true,"daemon":"already_running",...} exit 0 (idempotent,
don't double-spawn). Healthy in time → started, exit 0. Timeout → exit
100 with the log tail in the error message.
stop: not running → {"ok":true,"daemon":"already_stopped"} exit 0
(idempotent, not an error). Otherwise POST /_shutdown (+ token if
gated), poll /_health until down (5s timeout). Down → stopped, exit
0. Still up → exit 110.
status: running → exit 0; stopped → exit 3 (distinct, so a script
branches on $? without parsing JSON).
Verify before you're done
myapp serve --port 8080 &
sleep 0.2
curl -s localhost:8080/_health
curl -s -XPOST localhost:8080/_shutdown
curl -s localhost:8080/_health
myapp daemon start --port 8080
myapp daemon start --port 8080
myapp daemon status --port 8080
myapp daemon stop --port 8080
myapp daemon stop --port 8080
myapp daemon status --port 8080
myapp daemon start --host 0.0.0.0 --port 8080
curl -s -XPOST http://<lan-ip>:8080/_shutdown
curl -s -XPOST http://<lan-ip>:8080/_shutdown \
-H "Authorization: Bearer $(cat ~/.myapp/shutdown.token)"
Gotchas
- Never default
--host to 0.0.0.0. Loopback by default; wider is an
explicit opt-in.
- Never ship an unauthenticated
/_shutdown alongside a non-loopback
default or example. The token requirement isn't optional once you're off
loopback.
- Poll, don't sleep. A fixed
sleep() before checking health is flaky
(too short) or slow (too long). Use a short-interval poll with a bounded
total timeout.
- PID files go stale across a reboot or PID reuse —
kill(pid,0) only
proves some process has that PID, not that it's your daemon. Prefer the
HTTP mechanism whenever you have a server to probe.
- Respond before you exit in the
/_shutdown handler — an immediate
synchronous exit(0) can race the HTTP response write, so the caller sees a
connection reset instead of 200. Delay the exit slightly after writing the
response.
- machin's
listen() binds 0.0.0.0 unconditionally today — a machin
tool can accept --host for contract parity but must say in --help/guide
that it isn't yet enforced, rather than silently shipping a no-op flag. See
RECIPE.md Step 1 for the upstream fix path (listen_on(host, port)).