* feat: add hooks authoring instruction Add hooks.instructions.md with portable guidance for writing GitHub Copilot hooks: - Folder structure, config schema, and all field documentation - Script contract: stdin JSON, exit codes, stdout/stderr channels - Payload schemas for all common events (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, errorOccurred, agentStop) - Per-event deny mechanisms (structured JSON for preToolUse, non-zero exit for others) - Matcher support for host-level tool filtering - Three impactful examples: commit gate, auto-format, dangerous command blocker - Bash and PowerShell templates - Cross-platform guidance (Python through both entries) - Anti-patterns, design rules, and portability notes (GitHub Copilot vs Claude Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review comments - Document both toolArgs (JSON string) and tool_input/toolInput (object) variants with defensive parsing - Update sessionStart stdout to reflect additionalContext injection support - Document preToolUse stdout output fields: modifiedArgs/updatedInput, additionalContext - Add matcher field note about local verification - Remove undocumented notification event from event table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address round 2 review comments - Defensive toolArgs parsing in all 3 example scripts (handle toolArgs string, tool_input, toolInput) - PowerShell type check: test -is [string] before ConvertFrom-Json, init to null - commit-gate.sh: add package.json existence guard before jq - block-dangerous.sh: truncate command in deny reason and stderr to avoid leaking secrets - Consistent defensive parsing helper across all Bash examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: handle toolArgs as string or object in jq, fix short_cmd scoping - All 3 Bash example jq blocks now check toolArgs type before fromjson - short_cmd defined before the deny/log branch to avoid set -u error - Consistent defensive pattern across commit-gate, format-on-save, block-dangerous Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove accidentally committed agency.toml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: expand Portability Note into CLI vs VS Code vs Cloud Agent section Address Aaron's review: replace the thin portability bullet points with a concrete comparison table covering what is the same (config schema, event name casing, fields) and what differs (hook loading, shell environment, tool argument field names). Add a 'How to write portable hooks' checklist. Separate Claude Code into its own subsection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: simplify to toolArgs-only parsing, clean portability section - toolArgs is the documented contract — remove tool_input/toolInput defensive fallbacks from all examples and the Script Contract template - Simplify portability section: same config everywhere, one-line note about cloud agent requiring default branch - All 3 example scripts now use simple toolArgs pipe parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
20 KiB
description, applyTo
| description | applyTo |
|---|---|
| Portable guidance for authoring safe, fast, and clear hooks and reusable hook examples | .github/hooks/**, hooks/** |
Hook Authoring Guidelines
Hooks are small, deterministic commands or scripts that run at specific lifecycle events. An awesome hook does one clear job, runs quickly, and makes its side effects explicit.
Folder Structure
A GitHub Copilot hook lives in .github/hooks/ inside your repository:
.github/
└── hooks/
├── block-dangerous-commands.json ← hook config (which event, which script, options)
└── scripts/
├── block-dangerous-commands.sh ← Bash implementation
└── block-dangerous-commands.ps1 ← PowerShell implementation (optional if Bash-only)
You can have multiple .json files — each one registers hooks for one or more events. The host loads all of them.
The Config File
Each .json file maps events to an array of hook entries.
- Command hooks (
type: "command"): run a local script. The host passes event JSON on stdin, your script responds through exit code and stdout.
Config example
{
"version": 1,
"hooks": {
"preToolUse": [
{
"matcher": "bash",
"type": "command",
"bash": "./.github/hooks/scripts/block-dangerous-commands.sh",
"powershell": "./.github/hooks/scripts/block-dangerous-commands.ps1",
"cwd": ".",
"timeoutSec": 5,
"env": {
"BLOCK_MODE": "deny"
}
}
]
}
}
Config fields
| Field | Required | What it does |
|---|---|---|
type |
yes | "command" for scripts |
matcher |
no | Host-level filter — hook only fires when the tool name matches this value (e.g. "bash", "powershell", "edit", "create"). Locally verified working in Copilot CLI v1.0.36; not yet used in repo hook samples. |
bash |
one or both | Command line invoked on Unix / Bash-capable hosts |
powershell |
one or both | Command line invoked on Windows / PowerShell-capable hosts |
cwd |
no | Working directory, relative to repo root |
timeoutSec |
no | Max seconds before the host kills the process (default 30) |
env |
no | Extra process environment variables passed to the script |
Why matchers matter
Without a matcher, every preToolUse hook fires on every tool call. Your script starts with boilerplate like:
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
[[ "$tool_name" != "bash" ]] && exit 0
With a matcher, the host does this filtering for you — no boilerplate, no process spawn for irrelevant tools. This will likely become the standard pattern once the feature stabilizes.
If your hooks must work on both the CLI and the cloud agent (or on older CLI versions), keep the in-script filtering as a fallback even when using matchers.
env — static configuration for your script
env is a standard host field. The keys inside it are author-defined variables — you choose the names and values.
They arrive as process environment variables, not inside the stdin JSON payload. Use them for static configuration that should not be hardcoded:
| Pattern | Example |
|---|---|
| Mode flag | "BLOCK_MODE": "deny" — same script logs in one repo, blocks in another |
| Threshold | "MAX_CHANGED_FILES": "20" |
| Path | "AUDIT_LOG_PATH": ".github/logs/hooks.log" |
| Feature toggle | "ENABLE_NOTIFICATIONS": "false" |
bash and powershell — when to provide one or both
The host picks whichever entry matches the current environment. It does not run both, and does not fall back from one to the other.
| Situation | Provide |
|---|---|
| Private hook, one known platform | Only that platform's entry |
| Published hook claiming cross-platform support | Both entries |
| Single cross-platform runtime (Python, Node, pwsh) | Expose the same script through both entries |
| Bash-only dependency | bash only |
| Windows-only dependency | powershell only |
Cross-platform example using Python through both entries:
{
"type": "command",
"bash": "python3 ./.github/hooks/scripts/check.py",
"powershell": "python .\\.github\\hooks\\scripts\\check.py"
}
The Script Contract
Every hook script follows the same basic contract: read JSON from stdin, do work, and respond through exit code, stdout, and stderr.
Important: toolArgs is a JSON string, not a nested object. You must parse it a second time to access its fields.
Reading stdin and responding — Bash and PowerShell
Bash:
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
tool_args="$(printf '%s' "$payload" | jq -r '.toolArgs')"
command="$(printf '%s' "$tool_args" | jq -r '.command // ""')"
PowerShell:
Set-StrictMode -Version Latest
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$toolArgs = $payload.toolArgs | ConvertFrom-Json
$command = $toolArgs.command
To deny in preToolUse (PowerShell):
@{ permissionDecision = 'deny'; permissionDecisionReason = 'Blocked by policy' } |
ConvertTo-Json -Compress
exit 0
What the script receives
| Input | What it carries |
|---|---|
stdin |
One JSON payload describing the current event |
| process environment | Normal env vars plus any you defined under env in the config |
| working directory | cwd from the config, or the host default |
How the script responds
| Channel | Purpose |
|---|---|
exit 0 |
Script succeeded — host continues unless stdout carried a structured deny |
| non-zero exit | Blocks the triggering action and signals hook failure |
stdout |
Structured machine-readable output — only for events that document a stdout schema (like preToolUse) |
stderr |
Human-readable diagnostics for logs |
Exit codes and deny: the full picture
The deny mechanism depends on the event:
| Event type | How to allow | How to deny / block |
|---|---|---|
preToolUse |
exit 0, empty or {"permissionDecision":"allow"} on stdout |
Preferred: exit 0 + {"permissionDecision":"deny","permissionDecisionReason":"..."} on stdout — gives the host a reason to show. Also works: non-zero exit blocks the tool call, but without a structured reason. |
userPromptSubmitted |
exit 0 |
Non-zero exit blocks the prompt (stdout is ignored for this event) |
agentStop |
exit 0 |
Non-zero exit blocks the action |
Other events (sessionStart, sessionEnd, postToolUse, errorOccurred) |
exit 0 |
Non-zero exit signals failure; the host may skip subsequent hooks for that event |
Rule of thumb: if the event has a structured stdout schema (like preToolUse), use it — it gives a clean reason and is the officially documented deny path. For events without structured stdout, non-zero exit is the practical block mechanism — this is confirmed by repo samples and learning hub docs, though the official GitHub reference does not explicitly document "non-zero = block" as a contract guarantee.
Example 1: Commit gate — block commits until lint, types, and tests pass
Why this pattern matters: the deny reason includes the actual errors, so the agent sees what's broken and fixes it before trying again. This creates a self-correcting feedback loop — the most powerful thing hooks can do.
Event: preToolUse — fires before the agent runs git commit
Config — .github/hooks/commit-gate.json:
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./.github/hooks/scripts/commit-gate.sh",
"cwd": ".",
"timeoutSec": 120
}
]
}
}
Script — .github/hooks/scripts/commit-gate.sh:
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
# Only gate bash commands that are git commits
if [[ "$tool_name" != "bash" ]]; then exit 0; fi
command="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.command // ""')"
if ! printf '%s' "$command" | grep -q "git commit"; then exit 0; fi
CWD="$(printf '%s' "$payload" | jq -r '.cwd')"
ERRORS=""
# 1. TypeScript type check
if [[ -f "$CWD/tsconfig.json" ]]; then
TSC_OUT=$(cd "$CWD" && npx tsc --noEmit 2>&1) || ERRORS="${ERRORS}
=== TypeScript Errors ===
$(echo "$TSC_OUT" | head -30)"
fi
# 2. Lint
if [[ -f "$CWD/package.json" ]]; then
HAS_LINT=$(jq -r '.scripts.lint // empty' "$CWD/package.json" 2>/dev/null)
if [[ -n "$HAS_LINT" ]]; then
LINT_OUT=$(cd "$CWD" && npm run lint --silent 2>&1) || ERRORS="${ERRORS}
=== Lint Errors ===
$(echo "$LINT_OUT" | tail -30)"
fi
# 3. Tests
HAS_TEST=$(jq -r '.scripts.test // empty' "$CWD/package.json" 2>/dev/null)
if [[ -n "$HAS_TEST" ]]; then
TEST_OUT=$(cd "$CWD" && CI=true npm test -- --watchAll=false 2>&1) || ERRORS="${ERRORS}
=== Test Failures ===
$(echo "$TEST_OUT" | tail -30)"
fi
fi
if [[ -n "$ERRORS" ]]; then
jq -nc --arg reason "Cannot commit — fix these issues first:
$ERRORS" \
'{permissionDecision:"deny",permissionDecisionReason:$reason}'
fi
exit 0
What happens at runtime:
| Scenario | stdout | exit | Host action |
|---|---|---|---|
| All checks pass | empty | 0 |
Commit proceeds |
| Lint fails | {"permissionDecision":"deny","permissionDecisionReason":"Cannot commit — fix these issues first:\n=== Lint Errors ===\n..."} |
0 |
Blocks commit; agent sees the errors and fixes them |
| jq missing | empty | non-zero | Hook failure |
Example 2: Auto-format after file edits
Why this pattern matters: the agent writes code, and your formatter runs immediately after — no manual step needed. The agent's next read of that file sees the formatted version.
Event: postToolUse — fires after edit or create tool calls
Config — .github/hooks/format-on-save.json:
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"bash": "./.github/hooks/scripts/format-on-save.sh",
"cwd": ".",
"timeoutSec": 15
}
]
}
}
Script — .github/hooks/scripts/format-on-save.sh:
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
result_type="$(printf '%s' "$payload" | jq -r '.toolResult.resultType // ""')"
# Only format after successful file writes
case "$tool_name" in
edit|create) ;;
*) exit 0 ;;
esac
[[ "$result_type" != "success" ]] && exit 0
file_path="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.path // ""')"
[[ -z "$file_path" || ! -f "$file_path" ]] && exit 0
# Run the project's formatter — adapt to your stack
if command -v npx >/dev/null 2>&1 && [[ -f "package.json" ]]; then
npx prettier --write "$file_path" 2>/dev/null || true
elif command -v dotnet >/dev/null 2>&1 && [[ "$file_path" == *.cs ]]; then
dotnet format --include "$file_path" 2>/dev/null || true
fi
exit 0
What happens at runtime:
| Scenario | What the hook does | exit |
|---|---|---|
Agent edits src/app.ts successfully |
Runs prettier --write src/app.ts |
0 |
Agent runs bash ls |
Skips (not a file-writing tool) | 0 |
| Prettier not installed | Silently skips formatting | 0 |
Example 3: Block dangerous commands with structured deny
Why this pattern matters: the simplest guardrail — prevent destructive shell commands before they execute, with a clear reason the agent can read.
Event: preToolUse — fires before any tool call
Config — .github/hooks/block-dangerous.json:
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./.github/hooks/scripts/block-dangerous.sh",
"cwd": ".",
"timeoutSec": 5,
"env": {
"BLOCK_MODE": "deny"
}
}
]
}
}
Script — .github/hooks/scripts/block-dangerous.sh:
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
block_mode="${BLOCK_MODE:-log}"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
[[ "$tool_name" != "bash" ]] && exit 0
command="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.command // ""')"
if printf '%s' "$command" | grep -qE 'rm -rf /|git reset --hard|git clean -fd|git push.*--force'; then
# Truncate command to avoid leaking secrets in deny reason or logs
short_cmd="$(printf '%.80s' "$command")"
if [[ "$block_mode" == "deny" ]]; then
jq -cn --arg reason "Destructive command blocked: ${short_cmd}..." \
'{permissionDecision:"deny",permissionDecisionReason:$reason}'
else
echo "Would block: ${short_cmd}..." >&2
fi
fi
exit 0
What happens at runtime:
| Scenario | BLOCK_MODE | stdout | exit | Host action |
|---|---|---|---|---|
| Safe command | any | empty | 0 |
Proceeds |
git push --force |
deny |
{"permissionDecision":"deny",...} |
0 |
Blocks with reason |
git push --force |
log |
empty | 0 |
Proceeds (log only) |
Event Types
The full hooks reference is authoritative. Always check it for the latest payload shapes before writing a hook:
| Event | stdout | Typical use |
|---|---|---|
sessionStart |
parsed — additionalContext in stdout is injected into the session |
Setup, validation, context injection, logging |
sessionEnd |
ignored | Cleanup, summaries |
userPromptSubmitted |
ignored | Auditing, prompt blocking |
preToolUse |
parsed — permissionDecision, modifiedArgs/updatedInput, additionalContext |
Guardrails, deny/block, argument modification |
postToolUse |
ignored | Logging, formatting |
postToolUseFailure |
— | Recovery after a failed tool run |
agentStop |
— | Final validation |
subagentStart |
— | Subagent audit |
subagentStop |
— | Subagent output validation |
errorOccurred |
ignored | Diagnostics, alerts |
preCompact |
— | Pre-compaction work |
permissionRequest |
— | Approval workflow |
Payload schemas for common events
These are the payload shapes from the hooks reference. Always verify against the official reference for the latest fields.
sessionStart
{
"timestamp": 1704614400000,
"cwd": "/path/to/project",
"source": "new",
"initialPrompt": "Create a new feature"
}
source is "new", "resume", or "startup". initialPrompt is the user's first prompt if provided.
sessionStart stdout output — the host parses stdout for:
{
"additionalContext": "Current branch: main. Deploy target: staging."
}
additionalContext is injected directly into the session conversation, letting hooks provide environment-specific context dynamically.
sessionEnd
{
"timestamp": 1704618000000,
"cwd": "/path/to/project",
"reason": "complete"
}
reason is "complete", "error", "abort", "timeout", or "user_exit".
userPromptSubmitted
{
"timestamp": 1704614500000,
"cwd": "/path/to/project",
"prompt": "Fix the authentication bug"
}
The field is prompt — the exact text the user submitted.
preToolUse
{
"timestamp": 1704614600000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"rm -rf dist\",\"description\":\"Clean build directory\"}"
}
toolArgs is a JSON string — parse it a second time to access its fields.
preToolUse stdout output — the host parses stdout for:
| Field | What it does |
|---|---|
permissionDecision |
"deny" blocks the tool call. "allow" and "ask" also accepted; only "deny" is currently processed. |
permissionDecisionReason |
Human-readable reason shown to the user |
modifiedArgs or updatedInput |
Replacement tool arguments — used instead of the originals |
additionalContext |
Text injected into the agent's context for this turn |
postToolUse
{
"timestamp": 1704614700000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"npm test\"}",
"toolResult": {
"resultType": "success",
"textResultForLlm": "All tests passed (15/15)"
}
}
resultType is "success", "failure", or "denied".
errorOccurred
{
"timestamp": 1704614800000,
"cwd": "/path/to/project",
"error": {
"message": "Network timeout",
"name": "TimeoutError",
"stack": "TimeoutError: Network timeout\n at ..."
}
}
agentStop
{
"timestamp": 1704618000000,
"cwd": "/path/to/project"
}
Minimal payload — use it to trigger end-of-session actions like running git diff --stat or final validation.
When Hooks Are the Wrong Tool
| Avoid hooks for | Better fit |
|---|---|
| Open-ended reasoning or style guidance | Instructions, prompts, or agents |
| Long multi-step workflows with memory, retries, or branching | Agents, scripts, or workflow engines |
| Background daemons, watchers, debounce loops, or async jobs | Dedicated automation, services, or CI |
| Heavy repository-wide validation | CI, scheduled jobs, or dedicated automation |
Universal Design Rules
| Rule | Why it matters |
|---|---|
| One hook, one responsibility | Small hooks are easier to trust and debug |
| Default to observe first | Blocking or mutation should be an explicit choice |
| Keep hooks synchronous, bounded, and non-interactive | Hooks run in the critical path |
| Make hooks deterministic and idempotent | Re-runs should not create drift |
| Do not mutate branch, index, or worktree state by default | Git-destructive behavior is high risk |
| Treat prompts, tool arguments, and tool output as untrusted and sensitive | Input may be hostile or private |
| Redact secrets, credentials, tokens, and private content from logs | Logs often outlive the hook run |
Script Authoring Rules
- Validate the JSON fields you actually use
- Quote shell variables and never build commands from raw input
- Keep stdout clean unless the host requires structured output
- Use strict modes: Bash
set -euo pipefail, PowerShellSet-StrictMode -Version Latest - Check dependencies early and fail clearly if they are missing
- Avoid prompts, hidden installs, or environment mutation during execution
- Test scripts by piping representative JSON payloads into them manually
Choose the Smallest Viable Implementation
- PowerShell 7, Node.js, or Python for broadly portable hooks
- Bash where Bash is an explicit requirement or safe assumption
- An existing project CLI when the repository already depends on it
Do not introduce a new compiled runtime just to implement an ordinary hook.
Packaging a Reusable Hook
- Package config, scripts, and docs together
- Document the trigger event, purpose, side effects, dependencies, and disable path
- Explain what the hook reads, what it writes, and what it blocks
Anti-Patterns
- Long-running hooks, watchers, background daemons, or fire-and-forget async work
- Heavy scans on every event when a narrower trigger would do
- Hidden network calls or uploads in the critical path
- Silent mutation of Git state (checkout, reset, clean, stash, stage, commit, push, or history rewriting) by default
- Interactive prompts or implicit approval steps
- Noisy stdout, ad-hoc output formats, or mixed machine/human output
- Logging raw prompts, secrets, credentials, or large tool outputs
- Monolithic hooks that mix unrelated responsibilities
Portability
GitHub Copilot: CLI, VS Code, and Cloud Agent
The same .github/hooks/*.json config, the same payload schema, and the same script contract work across CLI, VS Code, and the cloud agent. Event names accept both camelCase (preToolUse) and PascalCase (PreToolUse). The documented payload field for tool arguments is toolArgs (a JSON string).
One thing to know: the cloud agent only loads hooks from the repository's default branch. If your hooks.json is only on a feature branch, the cloud agent won't see it.
Claude Code
Claude Code uses a different hook system:
- Settings in
~/.claude/settings.jsonand.claude/settings.json - Different event names and matcher syntax (regex,
ifconditions) - Exit 2 = block, exit 1 = non-blocking error (not the same as GitHub Copilot)
- 5 hook types (command, http, mcp_tool, prompt, agent)
- 29+ events including
FileChanged,CwdChanged,ConfigChange
The shared best practice is the same: keep hooks small, deterministic, explicit about I/O, and strict about side effects.