> Hooks allow you to extend and customize the behavior of [[GitHub Copilot]] agents by executing custom shell commands at key points during agent execution — such as when a session starts, before a tool runs, or when an error occurs.
🔗 **Source**: [Using hooks with GitHub Copilot agents](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) · [About hooks](https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks) · [Hooks configuration reference](https://docs.github.com/en/copilot/reference/hooks-configuration)
## What Are Hooks?
Hooks enable you to execute custom shell commands at strategic points in an agent's workflow. They receive detailed information about agent actions via JSON input on `stdin`, enabling context-aware automation. Hooks are available for:
- **Copilot coding agent** on GitHub (hooks stored at `.github/hooks/*.json` on the default branch)
- **GitHub Copilot CLI** in the terminal (hooks loaded from the current working directory)
Common use cases include:
- **Security enforcement** — programmatically approve or deny tool executions
- **Audit logging** — create compliance trails of all agent actions
- **Secret scanning** — prevent credential leaks before they happen
- **Custom validation** — enforce coding standards and project policies
- **Notifications** — alert teams on errors or sensitive operations
- **Cost tracking** — monitor tool usage for resource allocation
## Hook Types
There are **8 hook triggers** available:
| Hook | When It Fires | Can Control Execution? |
|---|---|---|
| `sessionStart` | New session begins or existing session resumes | No |
| `sessionEnd` | Session completes or is terminated | No |
| `userPromptSubmitted` | User submits a prompt | No |
| `preToolUse` | Before any tool runs (`bash`, `edit`, `view`, etc.) | ✅ Yes — can **deny** |
| `postToolUse` | After a tool completes (success or failure) | No |
| `agentStop` | Main agent finishes responding to a prompt | No |
| `subagentStop` | Subagent completes, before returning results to parent | No |
| `errorOccurred` | Error occurs during agent execution | No |
> [!tip] The `preToolUse` hook is the most powerful — it can **approve or deny** tool executions, making it essential for security policies.
## Creating a Hook
### Step 1: Create the Configuration File
Create a JSON file in `.github/hooks/` (e.g., `.github/hooks/my-hooks.json`):
```json
{
"version": 1,
"hooks": {
"sessionStart": [],
"sessionEnd": [],
"userPromptSubmitted": [],
"preToolUse": [],
"postToolUse": [],
"errorOccurred": []
}
}
```
> [!important] The file **must be present on the default branch** for Copilot coding agent to use it. For Copilot CLI, hooks are loaded from the current working directory.
### Step 2: Add Hook Definitions
Each hook is an object with these properties:
| Property | Required | Description |
|---|---|---|
| `type` | Yes | Must be `"command"` |
| `bash` | Yes (Unix) | Shell command or path to bash script |
| `powershell` | Yes (Windows) | PowerShell command or script path |
| `cwd` | No | Working directory (relative to repo root) |
| `env` | No | Additional environment variables (merged with existing) |
| `timeoutSec` | No | Max execution time in seconds (default: 30) |
### Step 3: Commit to Default Branch
Commit the file and merge it into the default branch. Hooks will run during agent sessions automatically.
## Hook Input & Output
### Input (All Hooks)
Every hook receives a JSON object on `stdin` with at minimum:
- `timestamp` — Unix timestamp in milliseconds
- `cwd` — current working directory
Additional fields depend on the hook type.
### Session Start Input
```json
{
"timestamp": 1704614400000,
"cwd": "/path/to/project",
"source": "new",
"initialPrompt": "Create a new feature"
}
```
- `source`: `"new"`, `"resume"`, or `"startup"`
### Session End Input
```json
{
"timestamp": 1704618000000,
"cwd": "/path/to/project",
"reason": "complete"
}
```
- `reason`: `"complete"`, `"error"`, `"abort"`, `"timeout"`, or `"user_exit"`
### User Prompt Submitted Input
```json
{
"timestamp": 1704614500000,
"cwd": "/path/to/project",
"prompt": "Fix the authentication bug"
}
```
### Pre-Tool Use Input
```json
{
"timestamp": 1704614600000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"rm -rf dist\",\"description\":\"Clean build directory\"}"
}
```
### Pre-Tool Use Output (Only Hook With Meaningful Output)
```json
{
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive operations require approval"
}
```
- `permissionDecision`: `"allow"`, `"deny"`, or `"ask"` (currently only `"deny"` is actively processed)
- If no output is returned, the tool is **allowed by default**
### Post-Tool Use Input
```json
{
"timestamp": 1704614700000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"npm test\"}",
"toolResult": {
"resultType": "success",
"textResultForLlm": "All tests passed (15/15)"
}
}
```
- `resultType`: `"success"`, `"failure"`, or `"denied"`
### Error Occurred Input
```json
{
"timestamp": 1704614800000,
"cwd": "/path/to/project",
"error": {
"message": "Network timeout",
"name": "TimeoutError",
"stack": "TimeoutError: Network timeout\n at ..."
}
}
```
## Full Configuration Example
```json
{
"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
}
],
"userPromptSubmitted": [
{
"type": "command",
"bash": "./scripts/log-prompt.sh",
"powershell": "./scripts/log-prompt.ps1",
"cwd": "scripts",
"env": {
"LOG_LEVEL": "INFO"
}
}
],
"preToolUse": [
{
"type": "command",
"bash": "./scripts/security-check.sh",
"powershell": "./scripts/security-check.ps1",
"cwd": "scripts",
"timeoutSec": 15
},
{
"type": "command",
"bash": "./scripts/log-tool-use.sh",
"powershell": "./scripts/log-tool-use.ps1",
"cwd": "scripts"
}
],
"postToolUse": [
{
"type": "command",
"bash": "cat >> logs/tool-results.jsonl",
"powershell": "$input | Add-Content -Path logs/tool-results.jsonl"
}
],
"sessionEnd": [
{
"type": "command",
"bash": "./scripts/cleanup.sh",
"powershell": "./scripts/cleanup.ps1",
"cwd": "scripts",
"timeoutSec": 60
}
]
}
}
```
## Writing Hook Scripts
### Reading Input (Bash)
```bash
#!/bin/bash
INPUT=$(cat)
TIMESTAMP=$(echo "$INPUT" | jq -r '.timestamp')
CWD=$(echo "$INPUT" | jq -r '.cwd')
```
### Reading Input (PowerShell)
```powershell
$input = [Console]::In.ReadToEnd() | ConvertFrom-Json
$timestamp = $input.timestamp
$cwd = $input.cwd
```
### Outputting JSON
```bash
# Compact single-line output with jq
echo '{"permissionDecision":"deny","permissionDecisionReason":"Policy violation"}' | jq -c
# Or construct dynamically
REASON="Too dangerous"
jq -n --arg reason "$REASON" '{permissionDecision: "deny", permissionDecisionReason: $reason}'
```
```powershell
$output = @{
permissionDecision = "deny"
permissionDecisionReason = "Security policy violation"
}
$output | ConvertTo-Json -Compress
```
### Error Handling
```bash
#!/bin/bash
set -e # Exit on error
INPUT=$(cat)
# ... process input ...
exit 0
```
```powershell
$ErrorActionPreference = "Stop"
try {
$input = [Console]::In.ReadToEnd() | ConvertFrom-Json
# ... process input ...
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
```
## Practical Examples
### Block Dangerous Commands (preToolUse)
```bash
#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName')
TOOL_ARGS=$(echo "$INPUT" | jq -r '.toolArgs')
echo "$(date): Tool=$TOOL_NAME Args=$TOOL_ARGS" >> tool-usage.log
if echo "$TOOL_ARGS" | grep -qE "rm -rf /|format|DROP TABLE"; then
echo '{"permissionDecision":"deny","permissionDecisionReason":"Dangerous command detected"}'
exit 0
fi
```
### Restrict File Edits to Specific Directories
```bash
#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName')
if [ "$TOOL_NAME" = "edit" ]; then
PATH_ARG=$(echo "$INPUT" | jq -r '.toolArgs' | jq -r '.path')
if [[ ! "$PATH_ARG" =~ ^(src/|test/) ]]; then
echo '{"permissionDecision":"deny","permissionDecisionReason":"Can only edit files in src/ or test/"}'
exit 0
fi
fi
```
### Code Quality Enforcement
```bash
#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName')
if [ "$TOOL_NAME" = "edit" ] || [ "$TOOL_NAME" = "create" ]; then
npm run lint-staged
if [ $? -ne 0 ]; then
echo '{"permissionDecision":"deny","permissionDecisionReason":"Code does not pass linting"}'
fi
fi
```
### Compliance Audit Trail
```json
{
"version": 1,
"hooks": {
"sessionStart": [{"type": "command", "bash": "./audit/log-session-start.sh"}],
"userPromptSubmitted": [{"type": "command", "bash": "./audit/log-prompt.sh"}],
"preToolUse": [{"type": "command", "bash": "./audit/log-tool-use.sh"}],
"postToolUse": [{"type": "command", "bash": "./audit/log-tool-result.sh"}],
"sessionEnd": [{"type": "command", "bash": "./audit/log-session-end.sh"}]
}
}
```
### Slack Notification on Error
```bash
#!/bin/bash
INPUT=$(cat)
ERROR_MSG=$(echo "$INPUT" | jq -r '.error.message')
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Agent Error: $ERROR_MSG\"}"
```
## Advanced Patterns
### Multiple Hooks of the Same Type
Multiple hooks for the same event execute **in order**:
```json
"preToolUse": [
{ "type": "command", "bash": "./scripts/security-check.sh" },
{ "type": "command", "bash": "./scripts/audit-log.sh" },
{ "type": "command", "bash": "./scripts/metrics.sh" }
]
```
### Conditional Logic — Only Validate Specific Tools
```bash
#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName')
# Skip non-bash tools
if [ "$TOOL_NAME" != "bash" ]; then
exit 0
fi
COMMAND=$(echo "$INPUT" | jq -r '.toolArgs' | jq -r '.command')
if echo "$COMMAND" | grep -qE "rm -rf|sudo|mkfs"; then
echo '{"permissionDecision":"deny","permissionDecisionReason":"Dangerous system command"}'
fi
```
### Structured JSON Lines Logging
```bash
#!/bin/bash
INPUT=$(cat)
TIMESTAMP=$(echo "$INPUT" | jq -r '.timestamp')
TOOL_NAME=$(echo "$INPUT" | jq -r '.toolName')
RESULT_TYPE=$(echo "$INPUT" | jq -r '.toolResult.resultType')
jq -n \
--arg ts "$TIMESTAMP" \
--arg tool "$TOOL_NAME" \
--arg result "$RESULT_TYPE" \
'{timestamp: $ts, tool: $tool, result: $result}' >> logs/audit.jsonl
```
## Performance Considerations
Hooks run **synchronously** and block agent execution. Keep these in mind:
- **Keep execution under 5 seconds** when possible
- **Use async logging** (append to files rather than synchronous I/O)
- **Background expensive operations** — don't block the agent
- **Cache results** of expensive computations
- Increase `timeoutSec` only when truly needed (default is 30s)
## Security Considerations
- **Validate and sanitize** all input processed by hooks — untrusted input could cause unexpected behavior
- **Use proper shell escaping** when constructing commands to prevent injection
- **Never log sensitive data** such as tokens or passwords
- **Set appropriate file permissions** on hook scripts and log files
- **Be cautious with external network calls** — they introduce latency and potential data exposure
- **Set appropriate timeouts** to prevent resource exhaustion
## Debugging
### Enable Verbose Logging
```bash
#!/bin/bash
set -x # Enable bash debug mode
INPUT=$(cat)
echo "DEBUG: Received input" >&2
echo "$INPUT" >&2
```
### Test Hooks Locally
```bash
# Pipe test input into your hook
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 .
```
## Troubleshooting
| Issue | Solution |
|---|---|
| Hooks not executing | Verify JSON is in `.github/hooks/`, check valid JSON syntax (`jq . hooks.json`), ensure `version: 1` is set, verify script is executable (`chmod +x`) and has a proper shebang (`#!/bin/bash`) |
| Hooks timing out | Increase `timeoutSec` in config, optimize script performance |
| Invalid JSON output | Ensure output is a single line, use `jq -c` (Unix) or `ConvertTo-Json -Compress` (PowerShell) |