Author correct macOS launchd plists for long-lived local daemons and design the EXTERNAL supervision-integrity check that catches what KeepAlive cannot self-heal: the supervising job itself getting unloaded. Use when writing or reviewing a LaunchAgent/LaunchDaemon plist, debugging a daemon that "silently died" after a brew upgrade or logout, choosing LaunchAgent vs LaunchDaemon, or fixing launchd's minimal-PATH "command not found" failures. NOT for systemd/Linux unit files, Windows services, ...
Pro scans all 11 files and shows the line behind each finding
Scanned 10/2/2026
npx -y skills add curiositech/port-daddy --skill macos-launchd-supervision --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Macos Launchd Supervision?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/curiositech-macos-launchd-supervision)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: macos-launchd-supervision
description: >-
Author correct macOS launchd plists for long-lived local daemons and design the EXTERNAL
supervision-integrity check that catches what KeepAlive cannot self-heal: the supervising job
itself getting unloaded. Use when writing or reviewing a LaunchAgent/LaunchDaemon plist,
debugging a daemon that "silently died" after a brew upgrade or logout, choosing LaunchAgent vs
LaunchDaemon, or fixing launchd's minimal-PATH "command not found" failures. NOT for
systemd/Linux unit files, Windows services, Docker healthchecks, or the daemon's own business
logic (see daemon-development).
license: Apache-2.0
allowed-tools: Read,Write,Edit,Bash,Grep,Glob
metadata:
category: Infrastructure & DevOps
tags:
- launchd
- macos
- plist
- daemon-supervision
- keepalive
provenance:
kind: first-party
owners:
- port-daddy
pairs-with:
- skill: daemon-development
reason: Supplies the daemon's own lifecycle, graceful shutdown, and IPC once launchd supervises it correctly.
- skill: macos-host-security
reason: Supplies entitlements, code signing, and sandboxing concerns adjacent to a launchd job.
- skill: port-daddy-internal-dev
reason: Home of the real `pd doctor supervision-integrity` precedent (PR #607) this skill generalizes.
io-contract:
kind: deliverable
consumes:
- kind: launchd-job-plan
format: json
produces:
- kind: plist-lint-report
format: json
- kind: annotated-plist
format: markdown
---
# macOS Launchd Supervision
Author launchd jobs that actually stay supervised, and build the external check that notices when they don't.
## Use This For
- Writing a LaunchAgent or LaunchDaemon plist for a long-lived local daemon (Label, ProgramArguments, RunAtLoad, KeepAlive, ThrottleInterval, log paths).
- Deciding LaunchAgent (`~/Library/LaunchAgents`, per-user GUI session) vs LaunchDaemon (`/Library/LaunchDaemons`, system-wide, no user session).
- Debugging "the daemon silently died and nothing restarted it" — almost always a supervisor that got unloaded, not a KeepAlive bug.
- Designing an EXTERNAL supervision-integrity check, modeled on `pd doctor`, that verifies the *supervisor* is loaded — something KeepAlive cannot verify about itself.
- Fixing launchd's minimal-PATH environment causing `command not found` for tools that work fine in a terminal.
## Do Not Use This For
- systemd unit files, Windows service registration, or any non-Darwin service manager — different lifecycle model entirely.
- Docker/container healthchecks and restart policies.
- The daemon's own business logic, protocol, or graceful-shutdown handling — see `daemon-development`.
## Process
```mermaid
flowchart TD
A[Define job: Label + ProgramArguments] --> B{GUI session needed?}
B -->|per-user, login items, Aqua| C[LaunchAgent: ~/Library/LaunchAgents/label.plist]
B -->|system-wide, boots before login| D[LaunchDaemon: /Library/LaunchDaemons/label.plist]
C --> E[RunAtLoad true + KeepAlive tuned + ThrottleInterval >= 10s]
D --> E
E --> F[StandardOutPath/StandardErrorPath to a durable log dir, never /tmp]
F --> G["Pin PATH: absolute ProgramArguments[0] or EnvironmentVariables.PATH"]
G --> H[launchctl bootstrap gui/UID or system label.plist]
H --> I{External supervision-integrity check}
I -->|supervisor loaded, daemon reachable| J[ok]
I -->|supervisor loaded, daemon unreachable| K[warn: kickstart the job]
I -->|supervisor label absent: brew upgrade, logout, bootout| L[critical: nothing will resurrect it]
L --> M[Re-bootstrap the supervisor, then verify again]
```
1. Decide Agent vs Daemon first: does the job need a logged-in GUI session (Aqua, notifications, keychain UI) or must it run before/without any user session? That answer decides the install location, not preference.
2. Write the plist: unique reverse-DNS `Label`, absolute-path `ProgramArguments`, `RunAtLoad=true` so it survives reboot/login, `KeepAlive` shaped to intent (bare `true`, or `{SuccessfulExit:false, Crashed:true}` to avoid restart-looping a clean exit), `ThrottleInterval >= 10` to stop thrash.
3. Route `StandardOutPath`/`StandardErrorPath` to a durable directory — `~/Library/Logs/<app>/` for an Agent, `/Library/Logs/<app>/` or `/var/log/<app>/` for a Daemon. Never `/tmp` or `/private/tmp`.
4. Pin the runtime environment: launchd jobs start with `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and no shell profile. Use absolute paths in `ProgramArguments` or set `EnvironmentVariables.PATH` explicitly.
5. Lint the plan with `scripts/plist_lint.mjs` before you ever run `launchctl bootstrap`.
6. Load it with the modern `launchctl bootstrap gui/$(id -u) <plist>` (Agent) or `launchctl bootstrap system <plist>` (Daemon) — not the deprecated `load`/`unload`.
7. Add an EXTERNAL supervision-integrity check, separate from both the daemon and its KeepAlive stanza, that runs `launchctl list <label>` for every legitimate supervisor label on a schedule or on `doctor`/health-check invocation, and classifies severity (ok / warn / critical) — because KeepAlive only acts while the job is loaded, and it cannot detect its own absence.
## Output Contract
`scripts/plist_lint.mjs` returns:
- `pass` — boolean, true only when zero `critical` findings remain.
- `findings` — array of `{ severity: "critical"|"warning", code, message }`, one per detected defect.
- `recommendations` — array of concrete fix strings, one per finding, in the same order.
Use `scripts/plist_lint.mjs` to lint a launchd job plan (JSON matching `schemas/launchd-plan.schema.json`) and get a deterministic pass/fail with actionable findings before you ship a plist.
## Anti-Patterns
### KeepAlive Means It'll Always Restart
**Novice**: Sets `KeepAlive: true` and treats the daemon as permanently self-healing — "launchd's got it."
**Expert**: `KeepAlive` only supervises *while the job is loaded*. A `brew upgrade`, a user logout, a `launchctl bootout`, or a corrupted plist unloads the job entirely, and nothing resurrects the daemon until something re-bootstraps the supervisor itself. Port Daddy hit this for real: Homebrew's `KeepAlive` on `homebrew.mxcl.port-daddy` supervised the daemon perfectly — until `brew upgrade` churn unloaded the job, and the daemon died silently with zero supervisors watching. The fix (PR #607) was `pd doctor supervision-integrity`: an external, 3-tier severity check (`ok` / `warn` / `critical`) that queries `launchctl list <label>` for every legitimate supervisor label, independent of the daemon's own health.
**Detection**: A plist with `KeepAlive` set and no separate process, cron, or `doctor` command that periodically confirms the supervisor label itself is still loaded — grep for `KeepAlive` with no companion `launchctl list` call anywhere in the codebase.
### Logging To /tmp
**Novice**: `StandardOutPath` and `StandardErrorPath` point at `/tmp/mydaemon.log` "because logs are temporary anyway."
**Expert**: macOS purges `/tmp` and `/private/tmp` on a schedule and on every reboot. The one log that would explain a boot-time crash is gone before anyone reads it — especially cruel because boot-time failures are exactly when you need durable logs. Use `~/Library/Logs/<app>/` for a LaunchAgent or `/Library/Logs/<app>/` / `/var/log/<app>/` for a LaunchDaemon.
**Detection**: `grep -E 'StandardOutPath|StandardErrorPath' *.plist` shows a value starting with `/tmp` or `/private/tmp`.
### Trusting Shell PATH Inside A launchd Job
**Novice**: `ProgramArguments: ["node", "server.js"]`, assuming `node` resolves the way it does in Terminal.
**Expert**: launchd jobs start with a minimal environment (`PATH=/usr/bin:/bin:/usr/sbin:/sbin`) and never source `.zshrc`/`.bash_profile`. Homebrew-installed binaries, `nvm` shims, and anything on a custom `PATH` all 404 as "command not found" even though `which node` works fine interactively. Use an absolute path (`/opt/homebrew/bin/node`) as `ProgramArguments[0]`, or set `EnvironmentVariables.PATH` explicitly in the plist.
**Detection**: `ProgramArguments[0]` is a bare command name (no leading `/`) and the plist has no `EnvironmentVariables.PATH` key.
## References
| File | Load When |
| --- | --- |
| `references/plist-field-reference.md` | Need the exact meaning and failure semantics of each plist key before writing one. |
| `references/agent-vs-daemon-and-integrity.md` | Need to decide LaunchAgent vs LaunchDaemon, or design a supervision-integrity check. |
| `examples/expected-output.md` | Need a worked example: a real annotated plist plus its lint report. |
| `templates/output-template.md` | Need a reusable skeleton for a plist-plus-lint deliverable. |
| `schemas/launchd-plan.schema.json` | Need to validate a launchd job plan before running the linter. |
| `scripts/plist_lint.mjs` | Need deterministic linting of a launchd job plan. |
| `agents/openai.yaml` | Need a subagent descriptor for delegated launchd plist authoring/review. |
<!-- BEGIN BUNDLE INDEX (auto: index_references.py) -->
## Skill Bundle Index
*Every file in this skill, and when to open it. Auto-generated; run `scripts/index_references.py --fix`.*
**root**
- [`CHANGELOG.md`](CHANGELOG.md) — macOS Launchd Supervision — Changelog — - Initial skill creation - Core process defined - Reference files and deterministic plist_lint script added
- [`README.md`](README.md) — macOS Launchd Supervision — Author correct launchd plists for long-lived local daemons, and design the external supervision-integrity check that catches what `KeepAlive
**`agents/`**
- [`agents/openai.yaml`](agents/openai.yaml) — openai (data/schema)
**`examples/`**
- [`examples/expected-output.md`](examples/expected-output.md) — Example Output: macOS Launchd Supervision — Scenario: Port Daddy's own daemon (`pd start --foreground`) needs a LaunchAgent that survives `brew upgrade` churn, logs durably, and is che
- [`examples/sample-input.json`](examples/sample-input.json) — sample input (data/schema)
**`references/`**
- [`references/agent-vs-daemon-and-integrity.md`](references/agent-vs-daemon-and-integrity.md) — LaunchAgent vs LaunchDaemon, and the Supervision-Integrity Pattern — Use this when deciding where a job lives, or when designing a check that verifies the supervisor itself.
- [`references/plist-field-reference.md`](references/plist-field-reference.md) — Plist Field Reference — Use this when writing or reviewing the actual `.plist` keys, not just the high-level plan.
**`schemas/`**
- [`schemas/launchd-plan.schema.json`](schemas/launchd-plan.schema.json) — launchd plan.schema (data/schema)
**`scripts/`**
- [`scripts/plist_lint.mjs`](scripts/plist_lint.mjs)
**`templates/`**
- [`templates/output-template.md`](templates/output-template.md) — Launchd Supervision Plan — - Label: `[reverse-DNS label]` - Placement: `[agent | daemon]` — because `[GUI-session need / boot-time need]` Run: Paste the lint report an
<!-- END BUNDLE INDEX -->
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!