Why your Copilot CLI hook or plugin isn't firing
Copilot CLI hooks live in JSON files — .github/hooks/ in the repository, ~/.copilot/hooks/ for yourself, or inside a plugin — and each entry pairs an event with a bash command, or a powershell command on Windows, for the CLI to run. What the command prints and returns feeds back into the session: a preToolUse hook, for example, can deny the tool call it was watching. A plugin is an installable bundle that can ship hooks, agents, skills, and MCP servers (the Model Context Protocol, the way the CLI reaches external tool servers). When a hook or plugin seems dead, the script is rarely the first suspect.
Most failures are one of four: the hook was never loaded, the event or matcher did not match, the handler failed, or its output never reached the model. Check them in that order. /env proves loading; the event table covers the second; a marker file and the session log cover the last two.
Run copilot --version first. The current release is 1.0.104 (27 October 2026), and hook and plugin loading changed several times this year.
Checked against GitHub's Copilot CLI documentation on 27 October 2026.
Status, 27 October 2026: the open plugin-hook report (#2540) was updated on 26 October 2026; a maintainer reply says fixes shipped since it was filed, most recently in 1.0.96, and asks for a retest on 1.0.103. A separate open report says a repository sessionStart hook does not fire while the same hook at user level works (#1730, open, updated 12 August 2026). On Windows, .claude/settings.json hooks run through PowerShell, so POSIX syntax and $CLAUDE_PROJECT_DIR fail (#4001, open, updated 26 October 2026).
The short answer: a repository hook in copilot -p is probably not loaded, because prompt mode skips repository hooks unless the folder is trusted or you opt in; a plugin hook is often looking at the wrong script path or a stale cached plugin; and a sessionStart hook that only echoes text may have run with nowhere for its output to go.
1. Is the hook loaded?#
The hooks reference lists the sources. These matter for local work:
| Source | Where it lives | Note |
|---|---|---|
| Repository hooks | .github/hooks/*.json in the repository root | A nested .github/hooks/ in a subdirectory is not this source. |
| User hooks | ~/.copilot/hooks/*.json, or $COPILOT_HOME/hooks/*.json | Available in every project; the best isolation test. |
| Inline repository settings | hooks key in .github/copilot/settings.json, settings.local.json, or .claude/settings*.json | Copilot reads a cross-tool subset of the .claude files. Inline hooks are strict. |
| Inline user settings | hooks key in ~/.copilot/settings.json | config.json is not read for hooks. |
| Plugin hooks | hooks.json or hooks/hooks.json in a legacy plugin; com.github.copilot/hooks/hooks.json in an Agent Plugins 1.0 plugin | Loaded with the plugin; not a separate hook system. |
| Policy hooks | /etc/github-copilot/policy.d/*.json on Linux and macOS; C:\ProgramData\GitHub\Copilot\policy.d\*.json on Windows | Machine-wide; not disabled by disableAllHooks. |
Copilot combines the sources; when the same event appears in several, all entries run. Each file is validated separately. In .github/hooks/*.json, a malformed item is dropped and logged while valid siblings load; invalid JSON, a bad version, or a non-array event list rejects the file. Inline hooks in settings.json are stricter: one bad item can take the whole hooks field. disableAllHooks can skip one file, a repository, or your user hooks; policy hooks ignore it. If /env shows no hooks, check that first.
Two loading traps catch most people.
The file is in the wrong .github. The documented repository path is the root, not the current working directory. In a monorepo, a .github/hooks/ beside your working directory is not the repository source. Move it to the root or to ~/.copilot/hooks/ and check again.
Prompt mode skips repository hooks. copilot -p runs one prompt and exits. Repository hooks are off by default there even when the same file loads interactively. They load when the folder is already trusted, with --trust-folder, with COPILOT_ALLOW_ALL, or with GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS=true. User hooks do not have that gate. If a hook works in the interactive session and vanishes in CI, check this first. In an interactive session, repository hooks also wait for folder trust.
Hook files are read at CLI start, so restart after editing; a new session is not always enough. Resume is separate: repository hooks on --resume were fixed in 1.0.6, plugin hooks in 1.0.97.
In an interactive session, /env lists loaded instructions, MCP servers, skills, agents, hooks, plugins, LSPs, and extensions, with hook counts and source paths; since 1.0.61 it shows full paths for hook sources. If your hook is not listed, stop debugging the script. In -p, use the session log or a marker file instead. For plugins, copilot plugin list --json shows name, version, enabled, source, and installedFrom; /plugin list is the in-session view. A disabled plugin contributes no hooks.
2. Is it the right event?#
Hook names are literal. These are the pairs people most often mix up:
| You expected | The event that actually fires | Why it matters |
|---|---|---|
A hook on every session, including -p | sessionStart | A command hook fires in -p when the folder is trusted; a prompt hook only in a new interactive session, not on resume or -p. |
| A hook to rewrite every prompt | userPromptSubmitted | Command and HTTP hooks drop modifiedPrompt; only SDK hooks can change it. |
| A hook before every approval | preToolUse, not permissionRequest | permissionRequest runs before the permission service, but read and hook requests short-circuit before it. |
| A hook after a tool fails | postToolUseFailure | postToolUse only runs after success. |
| A hook when a turn ends | agentStop | Fires when the main agent finishes a turn, not on Ctrl+C or Esc (#5075, open, updated 8 October 2026). |
| A hook for every subagent | subagentStart and subagentStop | A subagent is a separate agent the main session starts for a scoped job. The built-in general-purpose agent emits neither event. |
| A hook before compaction | preCompact | Compaction replaces older turns with a summary when the context window fills. This event is a notification only; its output is not processed. |
Other events include notification, errorOccurred, and userPromptTransformed. One event with a useful quirk is sessionEnd: /clear closes the old session and fires its sessionEnd hooks with reason: "user_exit" while the CLI keeps running. Those hooks run detached in the background.
The matcher is a full-match pattern#
A matcher filters the event. It is anchored with ^(?:PATTERN)$, so it must match the whole value; it is case-sensitive, and an invalid pattern skips the hook. Matchers exist on preToolUse, postToolUse, permissionRequest, notification, preCompact, and subagentStart.
The spelling of the event name chooses the payload and the matcher rules. preToolUse (lowercase first letter) uses toolName and toolArgs, runtime names (bash, edit, view), and an anchored regex. PreToolUse (capitalized) uses tool_name and tool_input, Claude names (Bash, Read, Write), and Claude's literal, |, or regex rules. The capitalized form is also the VS Code-compatible shape. So "matcher": "bash|edit" belongs with preToolUse; "matcher": "Bash|Read" with PreToolUse. Mixing them gives a loaded hook that never matches. subagentStart matches the full agent name; preCompact takes manual or auto.
3. Did the handler run?#
If /env lists the hook, and the event and matcher are right, the next failure is inside the command.
- The path is wrong. A relative script path resolves from the command's working directory. With no
cwd, that is the repository root, not the hook file's or plugin's directory. A hook under.github/hooks/that calls./scripts/check.shis looking for<repo>/scripts/check.sh. Use an absolute path or one from the repository root. - The script is not executable, or has no shebang. Run
chmod +xand start it with#!/usr/bin/env bash. On Windows, include apowershellcommand as well asbash; the CLI picks the key for the platform. - The command uses shell features without a shell. The
execform runs an executable directly, so pipes, redirection, and glob expansion are unavailable. Usebashorcommandwhen you need a shell; useexecwithargswhen you do not. - The hook timed out. The default is 30 seconds. Set
timeoutSechigher if the script does real work. Timeouts are fail-open for every event, includingpreToolUseand policy hooks: the CLI logs the timeout and continues, so a timeout is not a denial. - The script exits non-zero. On most events a non-zero exit is logged and skipped.
preToolUseis the strict one: a command hook that exits non-zero, including exit 2, fails closed and denies the tool call, even if its stdout saysallow. A broken guard script can look like "the hook blocks everything".permissionRequestalso treats exit 2 as a deny. HTTPpreToolUsehooks are fail-open. - The output is not one JSON object. The CLI concatenates the non-progress lines and parses them as one document. A shell banner before the JSON, or two decision objects in a row, makes the result invalid and the hook produces no output. Build JSON with
jq -c, not string concatenation. - The session sandbox is on. Repository, user, and plugin command hooks run inside the OS-level shell sandbox; a hook that reads or writes outside the workspace can fail. Add the path to
sandbox.userPolicyif it needs it. Policy hooks run on the host.
Progress lines are the exception: a line like {"type": "progress", "message": "Checking policy..."} is shown in the timeline and removed before the final JSON is parsed. Progress is display-only. Copilot CLI also does not document an async field; a Claude Code hook with "async": true still runs synchronously here, and a postToolUse hook written that way can block tool completion (#3063, open, updated 30 April 2026).
Prove the handler started#
Put echo "$(date -Is) preToolUse" >> /tmp/copilot-hook.log at the top of the script. If the file stays empty, the handler never started; go back to loading and matching. If it appears, the handler ran, and the problem is its exit code, output shape, or your expectation.
Run the script outside the CLI with a sample payload. The shape depends on the event; for sessionStart it starts like this:
echo '{"sessionId":"test","timestamp":0,"cwd":"/tmp","source":"startup"}' | ./hook.sh
echo $?In an interactive session, /session prints the log path. The default is ~/.copilot/logs/process-{timestamp}-{pid}.log. Start with --log-level debug, then search for the hook command, its stdout and stderr, and the timeout or failure message. Hook command timed out is the documented one; issue reports such as #1352 show the lines around it.
4. Did the output reach the model?#
A hook can run successfully and still look silent. Copilot CLI does not add every hook's stdout to the conversation.
sessionStartandsubagentStartconsumeadditionalContext, joined in execution order and bounded by a 10 MiB hook-output limit.postToolUseappendsadditionalContextto the tool result, capped at 10 KB;modifiedResultcan replace the result itself.notificationcan injectadditionalContextas a user message.preToolUseis a decision, not a context channel:permissionDecision,permissionDecisionReason, andmodifiedArgs.userPromptSubmittedfrom a command or HTTP hook drops its output, includingmodifiedPrompt.
The documented shape puts additionalContext at the top level:
{
"additionalContext": "This repository uses pnpm. Do not run npm install."
}Plain sessionStart stdout is neither displayed nor added to context; only additionalContext is consumed (#1352, open, updated 31 July 2026). Use a progress message for a visible line, additionalContext for the model. The context is injected once and not re-injected after compaction (#5225, open, updated 23 October 2026); put durable rules in an instruction file too.
For a delivery check, return {"additionalContext": "hook-nonce-7f3a"}. The marker proves the handler ran; the nonce in the model's answer is a hint, not proof. /env and the log are the evidence. Do not ask the model whether the hook loaded.
When the hook lives in a plugin#
A plugin hook is still a hook, with two extra layers: the plugin has to load, and the hook's path has to survive installation.
Check the plugin first. copilot plugin list --json shows name, version, enabled, source, and installedFrom; /env shows whether its hooks loaded. If the plugin is missing, the hook cannot fire. If it is disabled, enable it with copilot plugin enable NAME or change the repository setting that disabled it.
Check the hook location next. A legacy plugin can use hooks.json in the plugin root or hooks/hooks.json, and its plugin.json can point at a path or an inline object with the hooks field. An Agent Plugins 1.0 plugin (the newer portable format) must use com.github.copilot/hooks/hooks.json; the hooks manifest field is not part of that schema. A plugin that keeps hooks in .github/hooks/ inside its own directory is not using a documented plugin hook location.
Check the installed copy. A plugin installed from a local path is cached, and later sessions read the cached copy; edit and install again:
copilot plugin install ./my-pluginA path-sourced plugin in a local directory marketplace loads live from its real directory. A --plugin-dir mount is read-only and is not a persisted install, so copilot plugin disable cannot change its activation. Verify the hook in a normal install before blaming the loader.
Relative script paths are the most common plugin-specific failure. After installation the plugin lives under ~/.copilot/installed-plugins/{marketplace}/{plugin} or ~/.copilot/installed-plugins/_direct/{source}/, not in your repository. A hook with "cwd": "." resolves from the repository root, so ./scripts/guard.sh points at your project, not the plugin. Use the plugin root:
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "bash \"$PLUGIN_ROOT/scripts/guard.sh\"",
"cwd": "${PLUGIN_ROOT}",
"timeoutSec": 15
}
]
}
}Plugin hooks receive PLUGIN_ROOT, COPILOT_PLUGIN_ROOT, and CLAUDE_PLUGIN_ROOT with the plugin's installation directory. A cwd of ${PLUGIN_ROOT} is the workaround reported in the thread for #2540. If this fixes the hook, the loader was fine. If not, the open report (#2540) describes entries counted at startup that still do not execute; a likely cause is a script path resolved against the project after installation. Retest on 1.0.104 with a minimal plugin and /env before assuming the loader is broken.
Three more plugin-layer causes are worth ruling out.
- The update did not arrive. On Windows, an open report says
plugin updateand reinstall can restore from a local cache instead of fetching new code (#3627, open, updated 26 October 2026). Compare the version incopilot plugin list --jsonwith the version you published. - The plugin is enabled only by the repository. A repository
enabledPluginsentry is scoped to that repository, and an open report says it is ignored incopilot -pmode even when it works interactively (#4507, open, updated 26 October 2026). - The plugin was rejected. An unsupported Agent Plugins
$schemaversion rejects the plugin instead of falling back to legacy. A rootplugin.jsontargeting Agent Plugins also takes precedence over.plugin/plugin.jsonand.claude-plugin/plugin.json, so a stray root manifest can change what loads. If the plugin is installed but/envshows none of its components, start with the manifest.
On older builds, installed plugin agents, skills, and MCP servers were dropped in -p until 1.0.81; --plugin-dir agents and skills did not reach autocomplete until 1.0.97; plugin hooks on --resume were rebuilt from the wrong plugin list until 1.0.97.
When the hook came from Claude Code#
Copilot CLI reads a shared cross-tool subset of .claude/settings.json and .claude/settings.local.json, including hooks, disableAllHooks, enabledPlugins, extraKnownMarketplaces, and companyAnnouncements, and accepts Claude's nested matcher shape and PascalCase event names. That is useful, but not a guarantee that a Claude Code hook runs unchanged.
The biggest platform difference is Windows. Copilot CLI runs the single command string through PowerShell, while Claude Code runs it through bash. POSIX operators such as || and &&, $VAR, and $CLAUDE_PROJECT_DIR can fail or expand to nothing, and a failing PreToolUse hook denies the tool call. Two open reports describe this (#4001, open, updated 26 October 2026; #4399, open, updated 8 August 2026). If one file must serve both tools, keep the cross-tool command simple and use explicit bash and powershell entries in a Copilot hook file for platform-specific work.
async is the other migration trap: a hook marked "async": true still runs synchronously here. Background the work inside the script if it must not block, and accept that you lose stdout and exit-code handling.
Where to go next#
- GitHub Copilot CLI guides — the rest of the set.
- Why Copilot CLI ignores your AGENTS.md — if the rule loaded but the model still ignores it; a hook is the enforceable version.
- Custom agents in Copilot CLI — if the missing piece is a plugin agent, not a hook.
- Why Copilot CLI keeps asking for permission — if the hook is a workaround for an approval prompt.