Hooks and commands
Hooks and slash commands are the two APM primitives that do not pretend
to be portable. Unlike skills or instructions, they ship to a strict
subset of harnesses, never get folded into AGENTS.md, and rely on
each target’s own format. Author them when the value to a specific
harness justifies the per-target maintenance.
This page covers both. For the cross-harness reach map, see Primitives and targets. For dev-only versus prod separation in the manifest, see Dev-only primitives.
Why they are target-specific
Section titled “Why they are target-specific”A skill is a markdown file APM can route to every canonical skill target. A hook
is a runtime callback fired by one harness inside its own tool loop.
A slash command is a command-palette entry surfaced by an IDE.
Neither generalizes: nothing reaches AGENTS.md, nothing routes to
harnesses that lack the concept. Unsupported targets are silently
skipped, not errors. Treat both as opt-in surface, not as your
primary distribution path.
Source layout. APM discovers hook JSON files in either of two directories at the package root:
your-package/+-- .apm/| +-- hooks/| +-- pretool-validate.json+-- hooks/ # also discovered (Claude-native layout) +-- post-edit-format.jsonEach file is a JSON document keyed by lifecycle event. APM accepts the
Claude (PreToolUse, PostToolUse) and Copilot (preToolUse,
postToolUse) shapes; events are renamed per target during merge.
Session lifecycle event aliases
Section titled “Session lifecycle event aliases”| Source aliases | Copilot native key | Claude native key |
|---|---|---|
SessionStart, sessionStart |
sessionStart |
SessionStart |
Stop, AgentStop, agentStop |
agentStop |
Stop |
UserPromptSubmit, userPromptSubmit, userPromptSubmitted |
userPromptSubmitted |
UserPromptSubmit (native; the other two spellings are not renamed and will not fire) |
For Copilot and Claude, event names absent from this table are preserved unchanged. Only an unmapped
camelCase or PascalCase name that conflicts with the target convention emits
an install warning. All-lowercase names such as stop pass through silently;
stop is not a native Copilot or Claude event and will not fire.
{ "hooks": { "PreToolUse": [ { "hooks": [ {"type": "command", "command": "${PLUGIN_ROOT}/scripts/validate.sh", "timeout": 10} ] } ] }}APM also accepts the “naked” Claude settings-slice shape – event names at
the top level with no outer "hooks": wrap. This is the literal shape
Claude Code accepts inside its own settings.json, so a hooks slice copied
straight from there works as a standalone APM hook file:
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "${PLUGIN_ROOT}/scripts/validate.sh", "timeout": 10} ] } ]}Both shapes are normalized internally before merge. A file whose "hooks"
key is present but not a JSON object fails closed with a warning; a file
that parses cleanly but contributes zero entries also logs a warning so
authors notice empty merges during development.
The ${PLUGIN_ROOT}, ${CLAUDE_PLUGIN_ROOT}, ${CURSOR_PLUGIN_ROOT}, and ${KIRO_PLUGIN_ROOT}
tokens resolve to the installed package root and are rewritten per
target. Plain ./script.sh resolves relative to the hook file. If the hook
file lives in hooks/ or .apm/hooks/, a path like
./hooks/run-hook.sh resolves from the package root so the deployed
path is not doubled.
Quote the complete path when it may contain spaces:
"${PLUGIN_ROOT}/scripts/my hook.sh". A split-quoted path such as
"${PLUGIN_ROOT}"/scripts/my\ hook.sh is also accepted. If a supported
plugin-root token cannot be resolved, install names the package and explains
whether to balance the quotes, add a relative path, or keep the path inside the
package. Fix the package hook command, then run apm install again.
When a hook command points at a script inside a package hook directory, APM deploys the hook source bundle so sibling helper modules stay available at runtime:
- Claude-family merged targets (Claude, Cursor, Codex, Gemini, Antigravity, and Windsurf), Copilot, and Kiro receive the same bundle.
- Root hook JSON descriptors, symlinks, and
.apm-pinmarkers are not deployed. - JavaScript and TypeScript hook bundles get a minimal
package.jsonsidecar with the nearest source package’s Nodetype; packages without an explicittypedeploy ascommonjs, and shell-only bundles do not get a sidecar. Exception – Copilot and VS Code: APM does not write the sidecar into project.github/hooks/scripts/or user~/.copilot/hooks/scripts/because Copilot’s hook loader scans those directories recursively and rejects any JSON file that lacks ahookskey. APM also omits nested JSON bundle assets for these targets; keep hook runtime configuration in a non-JSON format. For ES module scripts targeting Copilot or VS Code, use the.mjsfile extension – Node.js recognises it without apackage.json.
Use simple hook filenames. APM computes their reach as:
effective hook targets = project active targets INTERSECT consumer per-dependency targets (when set) INTERSECT package targets (when restrictive)Every selector is a filter. A package declaration only narrows the
consumer-authorized active set; it never activates a target or expands
dependency reach. Omitting package target: / targets:, or using the
legacy all value, adds no package restriction; all is not expanded
into an additional target set at this gate.
For example, this package can write hooks only to Claude:
name: claude-hooksversion: "1.0.0"target: claudetarget: accepts scalar, CSV, and list spellings, including aliases such
as vscode (normalized to copilot). targets: accepts a scalar or list
of canonical names. Unknown names, both keys together, and an empty
or null targets: value fail validation. A null singular target: is
treated as omission for legacy compatibility; an empty string or list fails
validation. Both keys conflict even when either value is null. Consumers
can narrow one dependency further with object-form targets:.
If the same manifest stem is mirrored in both hooks/ and .apm/hooks/, APM
integrates the .apm/hooks/ copy once per target.
Supported targets and where the integrator writes:
| Target | Output | Mode |
|---|---|---|
| copilot | .github/hooks/<pkg>-<name>.json |
one file per hook |
| claude | .claude/settings.json |
merged into settings |
| grok-build | – not supported – | silently skipped |
| cursor | .cursor/hooks.json |
merged |
| gemini | .gemini/settings.json |
merged |
| codex | .codex/hooks.json |
merged |
| windsurf | .windsurf/hooks.json |
merged |
| kiro | .kiro/hooks/<package-slug>-<hook-file-stem-slug>-<event-slug>-<n>.json |
one file per hook action |
| opencode | – not supported – | silently skipped |
APM parses the source into vendor-neutral hook intent, then each target
integrator renders its native schema. Flat command entries become Claude’s
required { "matcher": "*", "hooks": [...] } entries in
.claude/settings.json. For Codex, flat command entries become intermediate
hook groups containing a nested hooks array in .codex/hooks.json. Kiro
receives its current v1 standalone schema:
{ "version": "v1", "hooks": [{ "name", "trigger", "matcher", "action" }] }.
Kiro trigger names are PascalCase and command timeouts remain in seconds.
Cursor native hooks and Claude import
Section titled “Cursor native hooks and Claude import”Cursor requires {"version": 1, "hooks": {...}} with flat handlers.
APM accepts native Cursor event names and these documented Claude aliases:
| Claude source | Cursor native event |
|---|---|
PreToolUse |
preToolUse |
PostToolUse |
postToolUse |
UserPromptSubmit |
beforeSubmitPrompt |
Stop |
stop |
SubagentStop |
subagentStop |
SessionStart |
sessionStart |
SessionEnd |
sessionEnd |
PreCompact |
preCompact |
For example, a Claude PreToolUse group with matcher Bash becomes:
{ "version": 1, "hooks": { "preToolUse": [ {"type": "command", "command": ".cursor/hooks/my-package/hooks/check.sh", "matcher": "Shell", "timeout": 10} ] }}Native Cursor spellings keep native matcher values unchanged. For the Claude
PreToolUse and PostToolUse aliases, APM translates literal tool alternatives:
Bash becomes Shell; Read, Grep, Task, WebFetch, and WebSearch keep
their names. Edit|Write becomes Write; either one alone is rejected because
Cursor combines both operations. Missing matchers and the unrestricted "",
*, and .* forms remain unrestricted.
APM rejects unknown events, other translated regexes, Glob, server-qualified
MCP matchers, and non-unrestricted matchers on other Claude aliases rather
than broadening their reach. It also rejects platform-specific commands,
unsupported handler fields/types, and invalid timeouts. Source documents may
contain hooks, optional version: 1, and a descriptive description string;
other source-level settings are rejected rather than silently discarded. Command and prompt
handlers retain native fields such as failClosed, model, and loop_limit.
Claude Stop and SubagentStop aliases get loop_limit: null to preserve the
documented unlimited import default rather than Cursor’s native default of five.
Native Cursor hooks keep their own defaults.
Cursor’s Include Third-Party Plugins, Skills, and Other Configs setting is on by default. All matching native and Claude-imported hooks run; native hooks do not replace imported hooks. APM therefore refuses overlapping native/import actions before deploying the package’s primitives. Checks cover planned Claude/Cursor sources and existing project, project-local, and user hook configs. “Owner” here means the installing dependency’s own declared provenance marker for that hook entry (recorded in the sidecar described below). Same-owner events – an event already attributed to the package about to install or retain the other side’s configuration – are treated conservatively as overlapping even when matchers differ; APM does not try to prove arbitrary regexes disjoint.
Choose one hook deployment route per dependency. To reuse existing Claude
hooks, select claude for that dependency and verify third-party imports are
enabled in Cursor. To use native-only events such as beforeReadFile, author
Cursor-native hooks and select cursor. APM neither changes import settings
nor writes Claude configuration for a Cursor-only dependency. Different
scripts can still have overlapping behavior; test them in the actual harness.
Reinstall migrates matching APM-owned legacy Cursor entries and keeps unrelated user hooks. Switching a dependency to one route can retire its old project route; it does not remove user hooks or matching hooks in another scope. Invalid or unsupported user configuration is rejected without being overwritten. Target-specific files retain the existing filename-routing rules; this repair does not add per-file target declarations or dry-run hook previews.
These mappings follow the Cursor hook reference and third-party hook reference. They are configuration compatibility, not a guarantee of identical execution semantics. Cloud Cursor agents support command hooks only.
Copilot hook files are namespaced with the source package name to avoid
collisions across installed deps; bundled scripts land alongside under
.github/hooks/scripts/<pkg>/.
Merged hook files contain only each target’s native upstream fields. APM writes
ownership metadata to a sibling apm-hooks.json sidecar for Claude, Cursor,
Gemini, Codex, Windsurf, and Antigravity. The sidecar is created and cleaned up
automatically alongside the native config; it is an APM implementation detail
and should not be edited by hand. When a target is dropped from targets: in
apm.yml, the next apm install, apm compile, or apm update also removes
that target’s own hook entries and sidecar – see
apm install’s target-contraction note
for the exact preserve/remove contract.
Verified against src/apm_cli/integration/targets.py and
src/apm_cli/integration/hook_integrator.py.
Commands
Section titled “Commands”Slash commands share their source with prompts. There is no
.apm/commands/ directory:
your-package/+-- .apm/ +-- prompts/ +-- review-pr.prompt.md # also routes as a slash commandFrontmatter the command integrator preserves: description,
allowed-tools, model, argument-hint, input. Other keys (for
example author, mcp, parameters) are dropped during the
transform and surfaced as install-time diagnostics.
---description: Review the current PR for security regressions.allowed-tools: [Read, Grep]argument-hint: "[pr-number]"input: - name: pr_number description: The PR number to review.---
Review pull request #$pr_number. Focus on auth and input handling.Supported targets and output paths:
| Target | Output | Format |
|---|---|---|
| copilot | – not a command – | ships as a prompt |
| claude | .claude/commands/<name>.md |
native markdown |
| grok-build | .grok/commands/<name>.md |
shared command transform |
| cursor | .cursor/commands/<name>.md |
claude-format subset |
| opencode | .opencode/commands/<name>.md |
opencode markdown |
| gemini | .gemini/commands/<name>.toml |
TOML |
| windsurf | .windsurf/workflows/<name>.md |
called “workflows” |
| codex | – not supported – | silently skipped |
Verified against src/apm_cli/integration/targets.py and
src/apm_cli/integration/command_integrator.py.
When NOT to use these
Section titled “When NOT to use these”Reach for a skill, instruction, or prompt first. Use hooks or commands only when the behavior is a runtime callback (hook) or a command-palette entry (command), you accept that consumers on Copilot, Codex, or OpenCode will not get them, and you will own per-target formats. “Run a script before every tool call” fits a hook. “Give the agent a procedure” fits a skill – and reaches every harness.
Pitfalls
Section titled “Pitfalls”- Hook event names. Use the documented session lifecycle aliases for Copilot and Claude. Their unknown names are preserved. An install warning appears only when an unmapped name starts with a capital letter or starts lowercase and contains a later capital letter, and that casing conflicts with the target convention. Cursor instead rejects unsupported events, fields and restrictions; see Cursor native hooks. Diagnostics name unsupported source-level, matcher-group and handler fields and identify whitespace in literal matcher alternatives without silently stripping it.
- Cursor command frontmatter loss. Cursor reuses the Claude command transformer today, so any prompt-only metadata is dropped with a diagnostic. Keep Cursor commands to the preserved key set.
- Script paths. Use
${PLUGIN_ROOT}(or the harness-specific alias) for scripts that ship inside the package, using the quoting forms described above. Plain absolute paths break on consumers’ machines. - Hook script path resolution.
apm install -g(user-scope) rewrites${PLUGIN_ROOT}and relative./references to absolute paths so Claude Code and Copilot CLI can execute scripts regardless of the working directory. Project-scopeapm install(no-g) keeps non-Claude command paths repo-relative. Claude project hooks useCLAUDE_PROJECT_DIR(or$env:CLAUDE_PROJECT_DIRfor PowerShell) so checked-in settings remain portable while hooks can run from outside the project directory. Either way, if a referenced script is missing at install time the installer emits a warning – in user-scope the unexpanded variable is rewritten to the absolute source path so the hook fails loudly at runtime; in project-scope the variable is left in place so the deployed config never embeds the installer’s machine-local prefix. - Same
.prompt.mdis two primitives. A single.apm/prompts/foo.prompt.mdbecomes Copilot’s prompt and Claude’s/foocommand in the same install. Name files with both surfaces in mind. - OpenCode and hooks. OpenCode has no hooks concept. Do not author a Claude+OpenCode package and assume hooks reach both – they do not. The install log notes the skip.
Once your hooks and commands are in place, inspect a real installation’s
native configuration, then run apm pack to bundle.
apm install --dry-run does not render hook previews.
See Compile and Pack a bundle.