Copilot hooks are shell commands (or, in Copilot CLI, HTTP calls and prompts) that GitHub Copilot runs at fixed points in an agent session, such as before a tool call (preToolUse) or when a session ends. You define them in JSON files under .github/hooks/ in a repository, and they work in Copilot CLI and Copilot cloud agent.

{
  "version": 1,
  "hooks": {
    "sessionStart": [
      {
        "type": "command",
        "bash": "echo \"Session started: $(date)\" >> logs/session.log",
        "powershell": "Add-Content -Path logs/session.log -Value \"Session started: $(Get-Date)\"",
        "cwd": ".",
        "timeoutSec": 10
      }
    ]
  }
}

Applies to: Copilot CLI and Copilot cloud agent, GitHub Docs as of October 2, 2026. Last checked: October 2, 2026.

The example above is from GitHub’s hooks how-to: it appends a line to logs/session.log each time a session starts, with a Bash command for Linux and macOS and a PowerShell command for Windows.

On this page

What can Copilot hooks do?

GitHub’s overview describes hooks as a way to run custom shell commands “at strategic points in an agent’s workflow”. Each hook gets JSON about the agent’s action on stdin. The docs list three typical jobs:

  • approve or deny tool executions programmatically;
  • use built-in security features such as secret scanning to prevent credential leaks;
  • add validation rules and audit logging for compliance.

Hooks are available in two places: Copilot cloud agent on GitHub and GitHub Copilot CLI in your terminal. The configuration format and payloads are mostly the same, but the hooks reference marks behavior that is “CLI only” or “Cloud agent only”.

Where do hook files go?

Location Surface Scope
.github/hooks/*.json in the repository CLI and cloud agent Every agent session in that repository
~/.copilot/hooks/*.json (%USERPROFILE%\.copilot\hooks\ on Windows, $COPILOT_HOME/hooks/ if set) CLI only Every CLI session for that user
hooks field in .github/copilot/settings.json or .github/copilot/settings.local.json CLI only That repository
hooks field in ~/.copilot/settings.json CLI only That user
.claude/settings.json and .claude/settings.local.json in the repository CLI only Read as cross-tool settings
A plugin’s hooks.json or hooks/hooks.json CLI only While the plugin is installed
/etc/github-copilot/policy.d/*.json (Windows: C:\ProgramData\GitHub\Copilot\policy.d\*.json) CLI only Machine-wide policy set by administrators

Per the reference, the CLI loads sources in the order policy, user, project, plugins, and runs every entry from every source when the same event appears more than once. Policy files on POSIX systems must be owned by root and not group- or world-writable; they cannot be turned off with disableAllHooks.

Cloud agent only reads .github/hooks/*.json from the cloned repository, and the cloud agent how-to says the file “must be present on your repository’s default branch”. The CLI loads hook configuration when it starts, so restart it after a change.

Because the CLI reads .claude/settings.json, hooks written for Claude Code in a repository are picked up too; our Claude Code hooks guide covers that format.

Which hook events exist?

Event Fires when Can change the outcome? Cloud agent
sessionStart A new or resumed session begins Can inject additionalContext Once per job
sessionEnd The session terminates No Once per job
userPromptSubmitted The user submits a prompt No for config-file hooks Once, for the job’s prompt
userPromptTransformed The prompt has been turned into model-facing content Can rewrite that content Fires
preToolUse Before each tool runs Allow, deny or modify Fires; "ask" counts as "deny"
postToolUse After a tool succeeds Modify the result or add context Fires
postToolUseFailure After a tool fails Add recovery guidance Fires
permissionRequest Before the permission service runs Allow or deny No effect (tools are pre-approved)
agentStop The main agent finishes a turn block forces another turn Fires
subagentStart / subagentStop A subagent is spawned / completes Stop can block Fires
preCompact Context compaction is about to start No Only trigger: "auto"
errorOccurred An error occurs No Fires
notification The CLI emits a system notification Can inject additionalContext Does not fire

Source: the hook events table. Event names can be written in camelCase (preToolUse, with camelCase payload fields) or in PascalCase (PreToolUse, with snake_case fields “to match the VS Code Copilot extension format”). PascalCase PreToolUse and PermissionRequest hooks also use Claude-style matchers and tool names, so Bash matches the CLI’s bash and powershell tools.

How is a hook entry configured?

Every file needs "version": 1 and a hooks object whose keys are event names and whose values are arrays of entries. There are three entry types (hook configuration format):

Type What runs Where
command bash, powershell or the cross-platform command string; in the CLI also exec with args, which skips the shell All events
http A JSON POST of the payload to url CLI; cloud agent only to firewall-allowed hosts
prompt Text or a slash command submitted as if the user typed it sessionStart only, new interactive CLI sessions

Command fields include cwd (relative to the repository root or absolute), env, and timeoutSec, which defaults to 30 seconds. An optional matcher filters some events; it is a regex compiled as ^(?:PATTERN)$, so it must match the whole value:

{
    "type": "command",
    "matcher": "bash|edit",
    "bash": "./scripts/log-tool.sh"
}

matcher is compared with toolName for preToolUse, postToolUse and permissionRequest, with notification_type for notification, with trigger for preCompact and with agentName for subagentStart. An invalid regex skips the entry.

HTTP hooks must use https:// URLs, except http://localhost, http://127.* and http://[::1] when COPILOT_HOOK_ALLOW_LOCALHOST=1 is set. For preToolUse and permissionRequest the URL must be https:// “because the response can grant tool permissions”.

{
  "version": 1,
  "hooks": {
    "postToolUse": [
      {
        "type": "http",
        "url": "https://hooks.example.com/copilot",
        "headers": { "X-Source": "copilot-cli" },
        "allowedEnvVars": ["GITHUB_TOKEN"],
        "timeoutSec": 30
      }
    ]
  }
}

What does a preToolUse hook receive and return?

A camelCase preToolUse hook gets this payload on stdin (input payloads):

{
    sessionId: string;
    timestamp: number;
    cwd: string;
    toolName: string;
    toolArgs: unknown;
}

GitHub’s CLI hooks tutorial notes that toolArgs arrives as a JSON string, so a script has to parse it before reading fields such as command. To decide, the hook writes one JSON object to stdout:

Field Values
permissionDecision "allow", "deny" or "ask"; empty output keeps the default behavior
permissionDecisionReason Shown to the agent; required for "deny"
modifiedArgs Replacement tool arguments

The tutorial’s deny response looks like this:

{"permissionDecision":"deny","permissionDecisionReason":"Privilege escalation requires manual approval."}

Command hooks can also print progress lines before the final object. Each must be a single-line JSON object; the rest of stdout is parsed as one JSON document, so two separate decision objects become invalid JSON and are ignored:

echo '{"type": "progress", "message": "Checking policy..."}'
# ... perform work ...
echo '{"permissionDecision": "allow"}'

What do exit codes and timeouts mean?

Result Effect (exit codes)
Exit 0 Success; stdout is parsed as the hook output
Exit 2 A warning for most events. For preToolUse and permissionRequest it is a deny, even if stdout says allow. For postToolUseFailure, stdout is added to the failure the agent sees
Other non-zero exit Logged and skipped, except preToolUse, which fails closed: “Denied by preToolUse hook (hook errored)”
Timeout Always fails open, including preToolUse and admin policy hooks: the tool call goes through the normal permission flow

HTTP preToolUse hooks are fail-open: a network error, timeout or non-2xx response falls back to the default permission flow. The reference says to “choose the variant that matches your security requirements”. If several preToolUse hooks run, any "deny" blocks the tool.

agentStop and subagentStop hooks can return {"decision": "block", "reason": "..."} to force another turn with reason as the prompt. After 8 consecutive blocks the CLI ends the turn anyway; the docs suggest reading the stop_hook_active input field to stop earlier.

How do hooks behave in Copilot cloud agent?

Cloud agent runs hooks inside its job sandbox (execution environment):

  • Linux only: bash (or command) entries run, powershell entries are ignored.
  • The working directory is /workspace when a repository is cloned, otherwise /root.
  • The filesystem is ephemeral, so log files written by hooks disappear when the job ends; GitHub suggests an http entry to keep output.
  • Outbound network is limited by the cloud agent firewall. By default only GitHub and Copilot hostnames are reachable.
  • The sandbox sets GITHUB_COPILOT_API_TOKEN, GITHUB_COPILOT_GIT_TOKEN and COPILOT_AGENT_PROMPT; GITHUB_TOKEN is not set.
  • The job is non-interactive with all tool permissions pre-granted, so preToolUse is the place for permission decisions.

Example: a CLI notification hook from GitHub’s docs

For a user-level hook on macOS, the how-to creates ~/.copilot/hooks/notification-hooks.json with this content, which plays a sound and shows a dialog when the agent stops and when the session ends:

{
  "version": 1,
  "hooks": {
    "agentStop": [
      {
        "type": "command",
        "bash": "osascript -e 'do shell script \"afplay /System/Library/Sounds/Funk.aiff &> /dev/null &\"' -e 'display dialog \"Agent stopped.\" with title \"Hook-generated message\" buttons {\"OK\"} default button \"OK\"'",
        "timeoutSec": 5
      }
    ],
    "sessionEnd": [
      {
        "type": "command",
        "bash": "osascript -e 'do shell script \"afplay /System/Library/Sounds/Funk.aiff &> /dev/null &\"' -e 'display dialog \"Session ended.\" with title \"Hook-generated message\" buttons {\"OK\"} default button \"OK\"'",
        "timeoutSec": 5
      }
    ]
  }
}

The same page has a Windows version using PowerShell. For a repository policy, GitHub’s tutorial builds .github/hooks/copilot-cli-policy.json with a sessionStart banner, a userPromptSubmitted audit log and a preToolUse script that denies sudo, rm -rf /, mkfs, dd and curl ... | bash patterns, with Bash and PowerShell versions of each script. It recommends a logging-first rollout before adding deny rules.

Security and performance notes

Warning

GitHub’s overview says to “always validate and sanitize the input processed by hooks”, use proper shell escaping, never log sensitive data such as tokens or passwords, and be cautious with hooks that make external network calls.

  • Hooks run synchronously and block the agent. GitHub suggests keeping each one under 5 seconds when possible.
  • The tutorial states that “GitHub does not currently provide built-in secret redaction for hooks”, so redact prompts and tool arguments before writing them to logs.
  • Set "disableAllHooks": true at the top of a hooks file to skip that file’s hooks without deleting them. At the top level of a repository settings.json it skips every non-policy hook for CLI sessions in that repository (disable all hooks).

How do I debug a Copilot hook?

From GitHub’s troubleshooting table: check that the file is in .github/hooks/, that it is valid JSON (jq . hooks.json), that version: 1 is set, and that the script is executable (chmod +x script.sh) with a shebang. Then pipe test input into the script:

# Create test input
echo '{"timestamp":1704614400000,"cwd":"/tmp","toolName":"bash","toolArgs":"{\"command\":\"ls\"}"}' | ./my-hook.sh

# Check exit code
echo $?

# Validate output is valid JSON
./my-hook.sh | jq .

For how Copilot’s agent features compare with another editor’s, see GitHub Copilot vs Cursor.