Skip to content

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.

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.json

Each 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.

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-pin markers are not deployed.
  • JavaScript and TypeScript hook bundles get a minimal package.json sidecar with the nearest source package’s Node type; packages without an explicit type deploy as commonjs, 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 a hooks key. 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 .mjs file extension – Node.js recognises it without a package.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-hooks
version: "1.0.0"
target: claude

target: 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 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.

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 command

Frontmatter 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.

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.

  • 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-scope apm install (no -g) keeps non-Claude command paths repo-relative. Claude project hooks use CLAUDE_PROJECT_DIR (or $env:CLAUDE_PROJECT_DIR for 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.md is two primitives. A single .apm/prompts/foo.prompt.md becomes Copilot’s prompt and Claude’s /foo command 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.