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(orcommand) entries run,powershellentries are ignored. - The working directory is
/workspacewhen a repository is cloned, otherwise/root. - The filesystem is ephemeral, so log files written by hooks disappear when the job ends; GitHub suggests an
httpentry 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_TOKENandCOPILOT_AGENT_PROMPT;GITHUB_TOKENis not set. - The job is non-interactive with all tool permissions pre-granted, so
preToolUseis 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": trueat the top of a hooks file to skip that file’s hooks without deleting them. At the top level of a repositorysettings.jsonit 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.





