AI Coding Assistant Hooks in Practice: Six Automation Patterns That Actually Work
Hooks are deterministic callbacks attached to lifecycle events of AI coding assistants: when a condition is met, an action runs automatically. This article explains the semantic model behind hooks (what they are, how they are configured, which events are worth mounting, and how to avoid false positives), maps the five common event categories and their naming across tools, and then walks through six production-ready patterns with runnable shell scripts: approval-wait notifications, high-risk command interception, incremental quality gates after file writes, async long-task notifications, project-convention injection at session start, and structured session archiving at session end. It closes with design principles, exit-code conventions, a three-phase rollout plan with quantified acceptance metrics, a minimal 10-minute setup, and a troubleshooting checklist.
Opening: Four Questions People Ask First
- What is a hook in an AI coding assistant? A deterministic callback attached to a lifecycle event of the assistant: when a given condition holds, an action runs automatically.
- How do you configure one? A hook configuration plus a set of ordinary shell scripts. The script receives JSON context on standard input and talks back to the model through its exit code and output.
- Which events are worth mounting first? Permission requests, before a tool call, after a file write, task start/end.
- How do you avoid blocking legitimate work? Keep rules explainable, versioned, and explicitly bypassable. Run in observe-only mode first, then switch to blocking.
Mainstream AI coding assistants all expose some form of hook capability, but event names and payload fields differ significantly. What follows is a semantic model; check the documentation for your specific tool for exact field names.
1. What a Hook Really Is: A Mount Point in the Agent Lifecycle
Don’t start from the API surface. Start from the problem: any automation loop has a minimal unit — when a condition holds, automatically perform an action. It is structurally identical to smart-home automation: open the blinds on a schedule, turn off the lights when the door locks, ring the alarm at a set time. The shared shape is trigger + condition + action + feedback channel:
- Trigger — who emits the signal;
- Condition — which rule must hold before acting;
- Action — run a local script, call an API, send a notification;
- Feedback — how the result gets back into a human’s field of view.
Move that onto an agent and the trigger becomes a runtime event: session start, permission request, about to modify a file, file write complete, a tool call finished, entering a long task, session end. Attach the rules ahead of time to the moments you care about, and things run themselves when they happen.
This is why “if you want to practice Loop Engineering, start with hooks”: the loop is driven by the model, but quality constraints, pacing, and human intervention points need deterministic mechanisms underneath. Hooks are that layer.
2. The Event Model: Decide Where to Mount Before What to Do
Semantically, events fall into roughly five categories. Build this map first, then look up the exact names in your tool’s documentation — it’s much faster.
| Event category | Typical trigger point | Common uses | Primary risk |
|---|---|---|---|
| Session-level events | Session start, resume, end | Inject project conventions, generate records, clean up temporary state | Oversized content crowding out context |
| Permission and approval events | Before a high-risk operation | Desktop notifications, second confirmation, rule-based auto-allow | Rules that are too broad remove the guardrail entirely |
| Pre-tool-call | Before a command or edit actually executes | Argument validation, sensitive-path blocking, prerequisite checks | Heavy logic slowing down interaction |
| Post-tool-call | After a write or command completes | Formatting, lint, type checking, diff summaries | Mishandled failures causing rework |
| Notification and long-task events | Entering the background, waiting required, execution complete | Desktop popups, chat-room messages, SMS/email | Too many notifications causing alert fatigue |
Naming reference (your tool’s docs are authoritative): pre-tool-call is commonly written as PreToolUse / pre_tool_use / beforeToolExecution; post-tool-call as PostToolUse or similar; session boundaries as SessionStart / SessionEnd or Start / Stop. Permission events may be a separate Notification event, or simply a match mode of PreToolUse. Some tools have no dedicated “background task completed” event — you have to record state at task start and settle it at Stop or SessionEnd yourself.
One key criterion for deciding what belongs in a hook: is the action deterministic or is it a judgment call? Deterministic work (run the tests, send the notification, format the code, check a path allowlist) goes to hooks, guaranteed to execute 100% of the time by code. Judgment work (is this implementation reasonable, is this design elegant) goes to the model. A lot of the efficiency loss on engineering teams comes from turning deterministic checks into “remind the model to remember to do this” — and the model will inevitably forget.
Exit Codes and Output Conventions
The standard way a hook receives context is a JSON document on standard input; output and exit code are the control signals. Most implementations follow “non-zero blocks, stderr is fed back to the model,” but the exact definitions are not fully consistent across tools, so confirm the semantics of the tool you’re configuring. It’s worth standardizing on three states yourself:
0: allow;2: block, and pass stderr back to the model as diagnostic information so it can self-correct within the current loop;1or anything else: a system error in the hook itself (missing script, unmet dependency, timeout). This must not be disguised as “the model wrote bad code” — allow by default and raise an alert; don’t let it freeze the entire loop.
Configuration checklist: whether the event name is supported by your version, matcher syntax (regex vs. exact match), timeout ceiling, whether blocking is actually fed back to the model, and the working directory and PATH of the hook script.
3. Six Hook Patterns Worth Trying
Pattern 1: Proactive Notification When Waiting for Approval
The pain: you issue an instruction, switch to something else, come back fifteen minutes later — and the task is still sitting at “requesting authorization.” The model is waiting for a human; the human is waiting for a meeting to end.
Implementation: subscribe to permission-type events and, on a hit, call a local notification capability (macOS osascript, Linux notify-send, PowerShell toast on Windows). The critical point is to never splice external content into the script source. Once osascript -e "..." receives a quote, a backtick, or $( ), the outcome ranges from a failed notification to local command execution. The safe approach is to pass content as an argument on argv, or filter it against an allowlist of characters first.
#!/usr/bin/env bash# notify.sh: approval request -> desktop notification (macOS example)set -euo pipefailpayload="$(cat)"op="$(printf '%s' "$payload" | jq -r '.message // .tool_name // "Operation needs confirmation"')"title='Coding assistant is waiting for approval'# Keep only allowlisted characters and truncate: no quotes, backticks, or command substitutionop_safe="$(printf '%s' "$op" | LC_ALL=C tr -c 'A-Za-z0-9 _-/:.,()[]' ' ' | cut -c1-120)"[ -z "${op_safe// /}" ] && op_safe='Operation needs confirmation'# Pass the content on argv instead of splicing it into AppleScript sourceosascript -e 'on run argvdisplay notification (item 1 of argv) with title (item 2 of argv) sound name "Glass"end run' "$op_safe" "$title"exit 0
On Linux, use notify-send "Coding assistant is waiting for approval" "$op_safe" — again argv passing, which is safe by construction. On Windows, prefer a parameterized interface such as pyperclip / MsgBox over embedding text into a PowerShell command string.
Caveat: the value of this kind of notification depends on whether it carries enough information. A bare “authorization required” popup is useless; include a command summary and many low-risk confirmations can be approved on experience alone. If the notification command doesn’t exist, the hook should degrade silently rather than error out and block.
Pattern 2: Pre-Execution Interception of High-Risk Operations
The pain: in contexts like “clean up temporary files” or “roll back changes,” the model occasionally produces a command with a blast radius far beyond what was intended. Human eyes miss it; a mechanism doesn’t.
#!/usr/bin/env bashset -uo pipefailpayload="$(cat)"cmd="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty')"[ -z "$cmd" ] && exit 0rules=('rm[[:space:]]+(-[a-zA-Z]*[rf][a-zA-Z]*[[:space:]]+)+/|[[:space:]]/($|[[:space:]])''git[[:space:]]+push[[:space:]].*--force''DROP[[:space:]]+TABLE|TRUNCATE[[:space:]]+TABLE''chmod[[:space:]]+-R[[:space:]]+777')for r in "${rules[@]}"; doif printf '%s' "$cmd" | grep -Eiq -- "$r"; thenprintf 'Blocked high-risk operation (matched rule: %s)\nConfirm manually, then rerun through an explicit allowlist channel: %s\n' "$r" "$cmd" >&2exit 2fidoneexit 0
Caveat: rules must be explainable, bypassable, and reviewable. Keep the ruleset in version control and let projects customize it; at the same time, provide an explicit “allow once” channel (for example a dedicated environment variable or a manual execution entry point). Once a security mechanism is proven to misfire frequently, developers turn the whole thing off — which is less safe than not having it. Interception rules should be guardrails, not roadblocks.
Pattern 3: Run an Incremental Quality Gate After Writes
The pain: code being written doesn’t mean it’s usable. A human remembers to run lint and type checks; the model may not. And if it does run them, it may not run the full set.
Implementation: mount on the post-file-write event and check only the files touched by this change; on failure, feed the error output back to the model so it can self-fix within the same loop. This script is an incremental gate and does not replace the full run in CI.
#!/usr/bin/env bash# lint.sh: run eslint only on the JS/TS files touched by this changeset -uo pipefailcd "${HOOK_REPO_ROOT:-.}" || { echo "Repository root is not available" >&2; exit 1; }# Read changed files one at a time, safely (no word splitting / glob expansion)changed=()while IFS= read -r f; do[ -n "$f" ] || continue[ -f "$f" ] || continue # skip deleted pathscase "$f" in*.ts|*.tsx|*.js|*.jsx) changed+=("$f") ;;esacdone < <(git diff --name-only --diff-filter=ACMR 2>/dev/null)[ "${#changed[@]}" -eq 0 ] && exit 0if npx --no-install eslint "${changed[@]}" --max-warnings=0; thenexit 0elserc=$?[ "$rc" -eq 0 ] && exit 0echo "Code quality check failed (eslint exit=${rc}). Fix the issues above before continuing." >&2exit 2fi
About filenames: don’t use npx eslint ${files} — an unquoted variable gets split on spaces and expanded by globs, so any filename containing a space will break. The while IFS= read -r loop above collects entries into an array and then expands it, which covers the space case. If filenames containing newlines are possible in the repository, switch to NUL-delimited input: mapfile -d '' -t changed < <(git diff --name-only -z --diff-filter=ACMR), then apply the same suffix filtering and -f check. When the file count is large (thousands, say), batch the invocations or degrade to “warn only, don’t block” so a single gate can’t run away with your time budget.
Pattern 4: Async Notification When a Long Task Finishes
The pain: a full test run, a build, or a cross-module refactor takes ten-plus minutes. Waiting next to it is a waste; walking away means missing the result.
Step one: record state when the task starts. On a start-type event (or at the point where a background task is dispatched), write a task identifier, a start timestamp, and a status file for the end event to settle.
#!/usr/bin/env bash# task_start.sh: record task ID and start timeset -euo pipefailstate_dir="${HOOK_STATE_DIR:-${TMPDIR:-/tmp}/agent-hooks}"mkdir -p "$state_dir"task_id="$(date +%s)-$"printf '%s\n' "$task_id" > "$state_dir/current.task"date +%s > "$state_dir/${task_id}.start"printf 'running\n' > "$state_dir/${task_id}.status" # overwrite, so it stays idempotentecho "$task_id"exit 0
Step two: aggregate and deliver when the task ends, and always have a degradation path.
#!/usr/bin/env bash# task_done.sh: summarize duration and outcome, deliver asynchronously in the backgroundset -uo pipefailstate_dir="${HOOK_STATE_DIR:-${TMPDIR:-/tmp}/agent-hooks}"log="$state_dir/notify.log"task_id="$(cat "$state_dir/current.task" 2>/dev/null || true)"if [ -z "$task_id" ]; thenprintf '%s [no-task] task ID not found, skipping notification\n' "$(date -Is)" >> "$log"exit 0 # hook's own problem, do not block the loopfistart="$(cat "$state_dir/${task_id}.start" 2>/dev/null || date +%s)"elapsed=$(( $(date +%s) - start ))code="${TASK_EXIT_CODE:-0}"if [ "$code" -eq 0 ]; thenmsg="Task finished in ${elapsed}s"elsemsg="Task failed (exit code ${code}) after ${elapsed}s"fiprintf '%s\n' "done" > "$state_dir/${task_id}.status"# Degradation 1: no channel configured -> local log only, plus feed the message back to the modelif [ -z "${NOTIFY_WEBHOOK:-}" ]; thenprintf '%s [local] %s\n' "$(date -Is)" "$msg" >> "$log"echo "$msg"exit 0fi# Degradation 2: background delivery with a hard timeout; failures go to the log, never block the main loop(payload="$(jq -nc --arg m "$msg" '{text:$m}')"curl -sS --connect-timeout 2 --max-time 5 \-H 'Content-Type: application/json' \-d "$payload" "$NOTIFY_WEBHOOK" >/dev/null 2>&1 \|| printf '%s [failed] %s\n' "$(date -Is)" "$msg" >> "$log") >/dev/null 2>&1 &disown 2>/dev/null || trueecho "$msg"exit 0
Cover three degradations: channel not configured (write to a local log and feed the message back to the model), network timeout (--max-time as a backstop; never let the hook hang), and delivery failure (write a failure log and keep the task state file so the next attempt can retry). Any chat bot or messaging service your team already runs is enough — there’s no need to stand up dedicated infrastructure just for notifications.
Caveat: notifications must be aggregatable. A single refactor can trigger dozens of write events, and pushing each one individually gets the channel muted in one click. Merge by session or by task, and push only three states: started / finished / needs a human.
Pattern 5: Inject Project Constraints at Session Start
The pain: branch naming, the dependency approval process, pre-commit checks, generated files being off-limits — you re-explain all of it in every new session, and every omission costs a round of rework.
#!/usr/bin/env bashcat <<'EOF'[Repository collaboration rules]1. Public APIs must carry type annotations and doc comments;2. Explain the reason before adding a runtime dependency; do not add one by default;3. Commits must pass `make verify` (lint + type check + unit tests);4. Files under generated directories (gen/, vendor/) must not be edited by hand;5. Database changes must ship a rollback script.Full spec: docs/CONTRIBUTING.md.EOFexit 0
Caveat: be restrained about length. Context is a finite resource, and stuffing in a few thousand lines of “complete development standards” dilutes the information that actually matters. Include only frequently-hit pitfalls and hard red lines, and point to a repository path for long documents in a single sentence.
Pattern 6: Automatic Archiving at Session End
The pain: for AI-authored changes, it’s hard to say afterward “what changed, why it changed that way, and how far it was verified.” Code review is left guessing, and retrospection is harder still.
#!/usr/bin/env bash# archive.sh: session end -> generate a redacted, structured recordset -uo pipefailts="$(date +%Y%m%d-%H%M%S)"out="${HOOK_ARCHIVE_DIR:-.agent-runs}/${ts}.md"mkdir -p "$(dirname "$out")"redact() { sed -E 's/(sk-|ghp_|AKIA)[A-Za-z0-9_-]+/[REDACTED]/g; s/(token|secret|password)[[:space:]]*=[[:space:]]*[^ ]+/\1=[REDACTED]/Ig'; }{echo "# Session record ${ts}"echo; echo "## Changed files"git diff --stat 2>/dev/null | redactecho; echo "## Verification results"echo "verify exit code: ${VERIFY_EXIT_CODE:-unknown}"} > "$out"chmod 600 "$out" 2>/dev/null || trueexit 0
Caveat: the first principle of archiving is redaction. Command lines, environment variables, and diffs can all carry credentials, so everything must pass a secret-pattern filter before it lands on disk. Also give the archive directory an explicit retention period and access permissions.
4. Design Principles: Make Hooks Infrastructure, Not Toys
- Fast, or non-blocking. Synchronous hooks should do purely local, millisecond-scale work; put every network operation in the background asynchronously.
- Must be idempotent. The same event may fire multiple times due to retries or session resume, so overwrite state files rather than doing unguarded accumulation or appends.
- Timeouts and degradation. Give every hook a hard timeout and handle expiry with a default policy (conservatively block in security contexts, allow in notification contexts). A single stuck script must not freeze the loop.
- Unambiguous exit-code semantics. Don’t conflate allow, block-and-let-the-model-fix, and system error.
- Configuration in version control. Personal global config holds notification preferences; team rules (security interception, quality gates) must travel with the repository, otherwise new members and CI will behave differently.
- Built-in observability. Hooks should emit logs and track trigger counts and failure rates. Automation you can’t observe can only be debugged by guessing.
- Watch for argument injection. Pass external content through standard input or environment variables; never splice it into a shell command string or script source, or the hook itself becomes an injection point.
5. A Phased Rollout Plan (with Quantified Acceptance Metrics)
Don’t mount every event at once. Order by benefit/risk and go in three steps.
Phase 1: Solve “is anyone there?” Add only the approval-wait notification and the long-task completion notification. Execution semantics don’t change, the risk is zero, and this typically saves the most waiting time immediately. Acceptance: first response latency after approval P95 ≤ 30 seconds, notification dismissal rate ≤ 10%, hook failure rate ≤ 1%.
Phase 2: Add quality gates. Incremental lint, formatting, type checking, pre-commit unit tests. Run in observe-only mode for two weeks first, collect the false-positive rate, and switch to blocking once it’s stable. Acceptance: gate false-positive rate < 5%, gate P95 runtime < 10 seconds, and model self-repair success rate after a block > 80%.
Phase 3: Add auditing and knowledge capture. Session archiving, change summaries, verification records. Acceptance: archive success rate > 99%, zero redaction misses, and complete records retrievable by task ID.
For delivery channels, just reuse what your team already has — code hosting, logging services, object storage, and monitoring/alerting from your cloud provider are all valid targets. Use whatever cloud services you actually run; there’s no need to build a separate pipeline just for automation.
6. A Minimal Working Example and a Troubleshooting Checklist
The minimal loop (verifiable in 10 minutes): write a hello.sh that prints one line, Hook is active, on the session-start event; then write a notify.sh that pops a notification on the permission event. Once both work, add complexity on top.
Troubleshooting checklist:
- No output / event never fires: run it by hand first,
echo '{}' | ./hook.sh; echo $?; confirm the event name and matcher are supported by your version; check the executable bit and shebang. jqfield comes back empty: dump the raw payload withcat > /tmp/payload.jsonand inspect the real field paths — field names vary widely between tools.osascript/notify-sendfails: check whether the content’s quotes broke it; switch to argv passing; confirm notification service permissions (macOS requires enabling it in System Settings).npxcan’t find dependencies: usenpx --no-install, or explicitlycdto the repository root andexport PATHinside the hook; otherwise you’ll hit a global cache instead of project dependencies.- Recursive triggering inside the hook: a command run by the hook fires the same event, creating a self-exciting loop. Set an explicit environment variable that makes commands skip hooks.
- Works locally, fails in CI: the hook references a variable that isn’t exported — always give defaults explicitly (
${VAR:-default}). - Bizarre failures: CRLF line endings, hardcoded interpreter paths, and unquoted paths with spaces account for most of the real debugging time.
- Cross-platform differences: shell syntax, path separators, and notification commands are entirely different across the three major systems; for shared team rules, take the smallest common subset and branch on platform.
Conclusion
The philosophy of hooks is simple: turn the things that “should happen every time” into things that “will definitely happen every time.” Hooks don’t change what the model can do, but they change how reliably that capability lands in practice — moving from dependence on human memory to dependence on mechanistic certainty. Start with approval and long-task notifications, then layer in safety guardrails, incremental quality gates, and audit archiving, and you have a loop that can run for the long haul. The judgment work can be left to the model with confidence.
