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 seven harnesses. 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.

{
"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}, and ${CURSOR_PLUGIN_ROOT} tokens resolve to the installed package root and are rewritten per target. Plain ./script.sh resolves relative to the hook file.

Supported targets and where the integrator writes:

TargetOutputMode
copilot.github/hooks/<pkg>-<name>.jsonone file per hook
claude.claude/settings.jsonmerged into settings
cursor.cursor/hooks.jsonmerged
gemini.gemini/settings.jsonmerged
codex.codex/hooks.jsonmerged
windsurf.windsurf/hooks.jsonmerged
opencode— not supported —silently skipped

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

Claude’s settings.json uses additionalProperties: false in its JSON schema, which rejects any unknown keys (including APM’s internal _apm_source ownership marker). APM therefore writes a companion sidecar file .claude/apm-hooks.json that stores the ownership metadata separately. This sidecar is created and cleaned up automatically alongside settings.json; it is an APM implementation detail and should not be edited by hand.

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
claude.claude/commands/<name>.mdnative markdown
cursor.cursor/commands/<name>.mdclaude-format subset
opencode.opencode/commands/<name>.mdopencode markdown
gemini.gemini/commands/<name>.tomlTOML
windsurf.windsurf/workflows/<name>.mdcalled “workflows”
copilot— not a command —ships as a prompt
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. Author in Claude or Copilot conventions only. The integrator renames; arbitrary event names will not be mapped.
  • 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 can execute scripts regardless of the working directory. Project-scope apm install (no -g) keeps command paths repo-relative so checked-in configs stay portable across clones, contributors, and CI. 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.