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 aliasesCopilot native keyClaude native key
SessionStart, sessionStartsessionStartSessionStart
Stop, AgentStop, agentStopagentStopStop

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.

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:

TargetOutputMode
copilot.github/hooks/<pkg>-<name>.jsonone file per hook
claude.claude/settings.jsonmerged into settings
grok-build— not supported —silently skipped
cursor.cursor/hooks.jsonmerged
gemini.gemini/settings.jsonmerged
codex.codex/hooks.jsonmerged
windsurf.windsurf/hooks.jsonmerged
kiro.kiro/hooks/<package-slug>-<hook-file-stem-slug>-<event-slug>-<n>.jsonone 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. 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.

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:

TargetOutputFormat
copilot— not a command —ships as a prompt
claude.claude/commands/<name>.mdnative markdown
grok-build.grok/commands/<name>.mdshared command transform
cursor.cursor/commands/<name>.mdclaude-format subset
opencode.opencode/commands/<name>.mdopencode markdown
gemini.gemini/commands/<name>.tomlTOML
windsurf.windsurf/workflows/<name>.mdcalled “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. 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 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. 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, run apm install --dry-run to preview what each target will receive, then apm pack to bundle. See Compile and Pack a bundle.