Files
awesome-copilot/instructions/hooks.instructions.md
Gordon Lam 5c0a993189 feat: add hooks authoring instruction 🤖🤖🤖 (#1507)
* 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>
2026-04-30 10:03:51 +10:00

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 parsedadditionalContext in stdout is injected into the session Setup, validation, context injection, logging
sessionEnd ignored Cleanup, summaries
userPromptSubmitted ignored Auditing, prompt blocking
preToolUse parsedpermissionDecision, 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, PowerShell Set-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

  1. PowerShell 7, Node.js, or Python for broadly portable hooks
  2. Bash where Bash is an explicit requirement or safe assumption
  3. 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.json and .claude/settings.json
  • Different event names and matcher syntax (regex, if conditions)
  • 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.