--- source_keys: - apm-cli-installed-source - apm-docs-llms-full --- # Authoring an apm hook Reached from `SKILL.md` Step 1 for a hook. `SKILL.md` Step 2 runs the Gate below; Step 3 writes against the shape and the checklist. ## Gate A hook is a runtime callback the harness fires inside its own tool loop — "this must always happen at this event", enforced deterministically rather than left to the model. apm's own guidance is to reach for a skill, instruction or prompt first, and to treat hooks as opt-in surface: they ship to a strict subset of harnesses and are silently skipped everywhere else. - **Procedure, know-how, or anything the model should decide to do** → a skill. Stop and hand to `skill-author`. - **The hook must reach only some harnesses** → reach is set by the package `apm.yml` `targets:`, never by the hook file. Stop and hand to `apm-workflow`. `targets:` is package-wide, so a harness-specific hook in a multi-target package means either a separate package or accepting that the other targets receive it too. - **A runtime callback** → continue. ## Shape One JSON file per concern at `.apm/hooks/.json`, with a plain name. Copy `assets/templates/hook.json.template`. Write the canonical shape apm documents and renders per target: ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ {"type": "command", "command": "${PLUGIN_ROOT}/.apm/hooks/check.sh", "timeout": 10} ] } ] } } ``` - **`${PLUGIN_ROOT}`** is the target-neutral token; apm rewrites it per target (`"${CLAUDE_PROJECT_DIR}/.claude/hooks//…"` on Claude, repo-relative elsewhere). `${CLAUDE_PLUGIN_ROOT}` is rewritten identically, so it is valid, but it ties the source to one harness's name (Should 11). - **Claude is the verified target.** apm 0.28.0 passes this nested shape to Copilot without reshaping it, and whether Copilot CLI runs nested entries or honours `matcher` is unverified. That gap is apm's to close. Per-file target routing is deprecated, so a Copilot-native flat hook (`bash` / `powershell` / `timeoutSec`) can only live in a separate Copilot-targeted package — hand that to `apm-workflow` rather than adding a second file here. ## Checklist Must: 1. The file sits directly in `.apm/hooks/`, is not a symlink, and parses as a JSON object. apm skips invalid JSON silently. (apm also discovers a package-root `hooks/`, and `factory-audit` accepts it for third-party packages; author in `.apm/hooks/`.) 2. Use the wrapped shape `{"hooks": {Event: [...]}}`. If a naked settings slice is used instead, every top-level value must be a list, with no stray scalar keys anywhere. 3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Anything else fails the Copilot install outright. The file contributes at least one entry, and every entry carries at least one handler: an empty list or a handler-less entry deploys nothing, with only a warning. 4. Every event is one that each target the package deploys to fires, after apm's rename for that target (`_HOOK_EVENT_MAP`; no `targets:` means every target). Write Claude's PascalCase names (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`, …), which apm renames for each target its map covers. A name the map does not cover deploys verbatim with no warning, so a lowercase `stop`, a camelCase `userPromptSubmit` or a typo such as `PreToolUSe` never fires on Claude or Copilot. A harness's own spelling (Cursor's `stop`, Windsurf's `pre_run_command`) belongs only in a package whose `targets:` reach no harness that would break it. 5. The script is referenced as `${PLUGIN_ROOT}/…` (or `${CLAUDE_PLUGIN_ROOT}/…`, see Shape) for the package root, or `./…` for the hook directory, and exists inside the package. The script is the command's first token or the first argument after an interpreter (`bash`, `sh`, `zsh`, `python`, `python3`, `node`, `pwsh`, `ruby`, `perl`), skipping its options (`-e`, `-u`, …) — after `-c`, the first token of the command string. In any position, no absolute path and no bare relative path (`scripts/x.sh`): apm bundles and rewrites neither. No `$` or backtick in the path itself, and no space. When quoting, quote the whole token — `"${PLUGIN_ROOT}/scripts/my-hook.sh"`, never `"${PLUGIN_ROOT}"/scripts/x.sh`: apm rewrites `${PLUGIN_ROOT}` only when a path separator follows it directly, and only up to the next space or quote, so a split quote is left unrewritten and a spaced path is cut short. The quoting and space rules are stricter than the research's Should, as with Must 6: either defect fails every time the hook fires. A missing script is only a warning at install time. 6. A script run directly as the command's first token, or as the first token of an interpreter's `-c` command string (`bash -c "${PLUGIN_ROOT}/x.sh"`), is executable. This is stricter than the research's Should: without it the hook fails every time it fires. A script passed to an interpreter (`bash ${PLUGIN_ROOT}/x.sh`) needs no executable bit. Should: 7. Every handler sets `"type": "command"` and an explicit `timeout` in seconds — apm passes `type` through but never supplies it. 8. Set `matcher` explicitly on tool events and on `SessionStart` (`startup`, `resume`, …). Omitted, Claude receives `"*"`. 9. Do not author Copilot's flat `bash` / `powershell` / `timeoutSec` keys in a Claude-shaped file; they render onto Claude as stray keys. 10. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled `.json` without a `hooks` key. 11. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`. 12. No filename that routes by target. Case-insensitively, apm routes a stem of exactly `hooks-` and any stem ending `-hooks` — bare (`claude-hooks`), prefixed (`x-claude-hooks`) or combined (`claude-codex-hooks`, the union). That routing is deprecated and reach belongs to `targets:` (see Gate). The research files this as a Must; it is a Should here because only the author can say deprecated routing is intended.