Hooks — Automating AI Agent Behavior with Lifecycle Triggers
READER BEWARE: THE FOLLOWING WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.
Introduction
Every CI/CD engineer knows the power of a well-placed Git hook. Before a commit lands, a pre-commit hook runs your linter. Before a push, a pre-push hook runs your test suite. These tiny lifecycle callbacks enforce consistency at machine speed, without relying on developers to remember.
AI coding agents are earning a seat at the same table — and they bring the same need for lifecycle hooks. When an agent edits a file, fetches a URL, invokes an external tool, or starts a new session, deterministic automation should fire automatically. Not because the model is untrustworthy, but because reliability, security, and compliance require it.
This post explains what agent lifecycle hooks are, how they map to patterns you already know from tools like Husky and GitHub Actions, and how to design a hook layer that enforces your team’s non-negotiables — before an agent ever touches production.
What Are Lifecycle Hooks?
A lifecycle hook is a callback that fires at a defined point in a process. The process could be a Git commit, a CI run, a deployment pipeline, or — increasingly — an AI agent session.
The hook contract is simple:
- A lifecycle event occurs (e.g., “agent is about to edit a file”).
- The platform invokes registered hooks synchronously or asynchronously.
- Hooks can inspect context, mutate state, emit logs, or abort the operation entirely.
This pattern is not new. What is new is applying it to the non-deterministic, multi-step actions of an LLM-based agent.
Prior Art: Hooks in Developer Tooling
Before designing agent hooks, it helps to study how the pattern has been battle-tested in traditional DevOps tooling.
Husky — Git Hooks for the Masses
Husky is a Node.js wrapper around Git’s built-in hook system. It lets you define hook scripts in your repository and ensures every developer runs them without manual setup.
# .husky/pre-commit
#!/bin/sh
npx lint-staged
# .husky/commit-msg
#!/bin/sh
npx commitlint --edit "$1"
Husky proves that hooks are most effective when they are:
- Declared in version control — everyone on the team runs the same hooks.
- Fast — slow hooks get disabled; fast hooks get respected.
- Scoped — each hook does one thing and does it well.
Pre-commit Framework — Polyglot Hook Management
The pre-commit framework extends the Git hook concept to multi-language projects. A single .pre-commit-config.yaml file declares hooks from remote repositories, pinned to exact versions.
repos:
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
The framework handles environment isolation, caching, and parallel execution. The operator configures policy; the tooling enforces it.
GitHub Actions — Workflow Hooks at Scale
GitHub Actions is a workflow engine built entirely on event-driven hooks. Workflow triggers (on:) map directly to lifecycle events: push, pull_request, release, schedule, workflow_dispatch.
# .github/workflows/ci.yml
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Security scan
uses: snyk/actions/node@master
GitHub Actions demonstrates that hooks work at any scale — from a single repository to an enterprise monorepo — and that the same hook infrastructure can serve security, quality, and observability goals simultaneously.
Danger — Automated Code Review as a Hook
Danger JS plugs into the pull request lifecycle to automate code review policy. A Dangerfile describes rules that Danger evaluates on every PR, posting inline comments for violations.
// Dangerfile.js
import { danger, warn, fail, message } from "danger";
// Enforce PR size limits
const bigPRThreshold = 500;
if (danger.github.pr.additions + danger.github.pr.deletions > bigPRThreshold) {
warn(`PR is too large. Consider splitting it into smaller changes.`);
}
// Require changelog updates
const hasChangelog = danger.git.modified_files.includes("CHANGELOG.md");
if (!hasChangelog) {
fail("Please include a CHANGELOG.md update.");
}
// Check for hardcoded secrets
const newFiles = danger.git.created_files;
for (const file of newFiles) {
const content = danger.github.utils.fileContents(file);
if (/password\s*=\s*['"][^'"]+['"]/i.test(content)) {
fail(`Possible hardcoded password found in ${file}`);
}
}
Danger shows that hooks can express nuanced, context-aware policy — not just pass/fail binary checks.
Mapping Hooks to AI Agent Lifecycles
AI agents perform actions that have obvious hook attachment points. The table below maps agent actions to hook opportunities:
| Agent Action | Pre-Hook Opportunity | Post-Hook Opportunity |
|---|---|---|
| Fetch a URL | Validate domain, enforce allowlist | Cache response, log access |
| Edit a file | Snapshot state, validate target path | Run formatter, run linter, diff review |
| Call an external tool | Validate tool name, check permissions | Log output, enforce rate limits |
| Start a session | Load project metadata, hydrate context | — |
| End a session | — | Generate audit log, summarize changes |
| Execute a shell command | Sandbox check, dry-run in staging | Capture output, alert on failure |
The key insight is that hooks give you deterministic control at non-deterministic boundaries. The agent may decide what to do next; hooks control how and whether that action is carried out.
Designing Your Hook Layer
Pre-URL Fetch Hooks
When an agent retrieves a URL — to read documentation, fetch an API response, or check a dependency — a pre-fetch hook can enforce your organization’s network policy before the request leaves the machine.
# hooks/pre_url_fetch.py
import re
from urllib.parse import urlparse
ALLOWED_DOMAINS = {
"docs.python.org",
"pkg.go.dev",
"registry.npmjs.org",
"api.github.com",
"raw.githubusercontent.com",
}
BLOCKED_PATTERNS = [
re.compile(r"\.onion$"),
re.compile(r"^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)"), # RFC 1918
re.compile(r"localhost|127\.0\.0\.1|0\.0\.0\.0"),
]
def pre_url_fetch(url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname or ""
# Enforce scheme
if parsed.scheme not in ("https",):
raise PermissionError(f"Non-HTTPS URL blocked: {url}")
# Enforce domain allowlist
if hostname not in ALLOWED_DOMAINS:
raise PermissionError(f"Domain not in allowlist: {hostname}")
# Block internal network access
for pattern in BLOCKED_PATTERNS:
if pattern.search(hostname):
raise PermissionError(f"Internal/blocked address: {hostname}")
This hook pattern prevents an agent from being tricked into exfiltrating data to attacker-controlled domains or making requests to internal services it should not reach.
Post-Edit Hooks
After an agent modifies a file, a post-edit hook enforces code quality automatically — no manual review required for mechanical issues.
#!/bin/bash
# hooks/post_edit.sh
# Arguments: $1 = file path that was edited
FILE="$1"
EXTENSION="${FILE##*.}"
if [ ! -f "$FILE" ]; then
echo "post_edit: file not found, skipping: $FILE"
exit 0
fi
case "$EXTENSION" in
py)
echo "Running Black formatter..."
black "$FILE"
echo "Running Ruff linter..."
ruff check --fix "$FILE"
;;
ts|tsx|js|jsx)
echo "Running Prettier..."
npx prettier --write "$FILE"
echo "Running ESLint..."
npx eslint --fix "$FILE"
;;
go)
echo "Running gofmt..."
gofmt -w "$FILE"
echo "Running staticcheck..."
staticcheck "$FILE"
;;
*)
echo "No formatter configured for .$EXTENSION"
;;
esac
echo "post_edit: complete for $FILE"
Pairing this with a diff check gives you an audit trail: every file the agent touched was automatically validated by the same tools your CI pipeline uses.
You can extend this pattern to run security scanners:
# Append to post_edit.sh for any file
echo "Running secrets scanner..."
gitleaks detect --source "$(dirname "$FILE")" --no-git --redact
echo "Running SAST..."
semgrep --config=p/owasp-top-ten "$FILE" --json | \
jq '.results[] | select(.severity == "ERROR")' | \
tee /tmp/semgrep-results.json
ERRORS=$(wc -l < /tmp/semgrep-results.json)
if [ "$ERRORS" -gt 0 ]; then
echo "SAST found $ERRORS critical issues. Stashing edits and blocking."
git stash push -m "agent-edit-blocked-by-sast-$(date +%s)" -- "$FILE"
exit 1
fi
Session Start Hooks
When an agent starts a new coding session, it needs context. A session-start hook can load that context automatically, hydrating the agent’s working memory with information it would otherwise have to ask for — or worse, hallucinate.
# hooks/session_start.py
import os
import re
import subprocess
import json
from datetime import datetime
def session_start(session_id: str, working_directory: str) -> dict:
context = {}
# Load project metadata (Node.js projects only)
pkg_path = os.path.join(working_directory, "package.json")
if os.path.exists(pkg_path):
with open(pkg_path) as f:
pkg = json.load(f)
context["project_name"] = pkg.get("name")
context["project_version"] = pkg.get("version")
context["node_version"] = pkg.get("engines", {}).get("node", "unspecified")
# Load active Git branch and recent commits
context["git_branch"] = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=working_directory,
text=True,
).strip()
context["recent_commits"] = subprocess.check_output(
["git", "log", "--oneline", "-10"],
cwd=working_directory,
text=True,
).strip()
# Fetch Jira ticket context from branch name
branch = context["git_branch"]
ticket_match = re.search(r"([A-Z]+-\d+)", branch)
if ticket_match:
ticket_id = ticket_match.group(1)
try:
context["jira_ticket"] = fetch_jira_ticket(ticket_id)
except Exception as exc:
# Non-fatal: log and continue if Jira is unreachable
print(f"Warning: could not fetch Jira ticket {ticket_id}: {exc}")
# Load team conventions
conventions_path = os.path.join(working_directory, ".github", "copilot-instructions.md")
if os.path.exists(conventions_path):
with open(conventions_path) as f:
context["team_conventions"] = f.read()
context["session_started_at"] = datetime.utcnow().isoformat() + "Z"
return context
def fetch_jira_ticket(ticket_id: str) -> dict:
import requests
try:
response = requests.get(
f"https://yourorg.atlassian.net/rest/api/3/issue/{ticket_id}",
auth=(os.environ["JIRA_USER"], os.environ["JIRA_TOKEN"]),
timeout=5,
)
response.raise_for_status()
except requests.exceptions.Timeout:
raise RuntimeError(f"Jira API timed out for ticket {ticket_id}")
except requests.exceptions.RequestException as exc:
raise RuntimeError(f"Jira API request failed: {exc}") from exc
issue = response.json()
return {
"id": ticket_id,
"summary": issue["fields"]["summary"],
"description": issue["fields"]["description"],
"status": issue["fields"]["status"]["name"],
"acceptance_criteria": issue["fields"].get("customfield_10016"),
}
Loading Jira context at session start means the agent understands why it is making a change, not just what to change. That intent grounding significantly reduces scope creep and misaligned implementations.
Session End Hooks
When the agent finishes a session, a session-end hook generates the engineering artifact that compliance teams, auditors, and your future self will need: an accurate record of what changed, why, and under what authority.
# hooks/session_end.py
import json
import subprocess
from datetime import datetime
from pathlib import Path
def session_end(session_id: str, session_context: dict, working_directory: str) -> None:
audit = {
"session_id": session_id,
"started_at": session_context.get("session_started_at"),
"ended_at": datetime.utcnow().isoformat() + "Z",
"agent_model": session_context.get("agent_model", "unknown"),
"operator": session_context.get("operator_id"),
"jira_ticket": session_context.get("jira_ticket", {}).get("id"),
"git_branch": session_context.get("git_branch"),
"files_modified": [],
"hooks_executed": session_context.get("hooks_executed", []),
}
# Collect all files modified during the session
diff_output = subprocess.check_output(
["git", "diff", "--name-status", "HEAD"],
cwd=working_directory,
text=True,
)
for line in diff_output.strip().splitlines():
if line:
status, *paths = line.split("\t")
audit["files_modified"].append({"status": status, "path": paths[-1]})
# Write audit log
log_dir = Path(working_directory) / ".audit" / "agent-sessions"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{session_id}.json"
with open(log_path, "w") as f:
json.dump(audit, f, indent=2)
print(f"Audit log written to {log_path}")
# Optionally ship to your SIEM or observability platform
# ship_to_splunk(audit) # Implement using your org's Splunk HEC endpoint
This log is structured JSON — queryable, diffable, and ready for ingestion by any SIEM or observability platform. When an auditor asks “what did the AI agent do last Tuesday?”, you have an answer in seconds rather than hours of log archaeology.
Composing a Full Hook Pipeline
In practice, hooks compose into a pipeline. Here is a YAML-based hook configuration modeled after how GitHub Actions and pre-commit express similar ideas:
# .agent-hooks.yaml
hooks:
pre_url_fetch:
- id: domain-allowlist
run: python hooks/pre_url_fetch.py
fail_fast: true
post_edit:
- id: auto-format
run: bash hooks/post_edit.sh
pass_filenames: true
- id: sast-scan
run: semgrep --config=p/owasp-top-ten
pass_filenames: true
fail_fast: true
session_start:
- id: load-project-context
run: python hooks/session_start.py
- id: fetch-ticket-context
run: python hooks/session_start.py --jira
- id: notify-team-channel
run: bash hooks/notify_slack.sh session_started
session_end:
- id: generate-audit-log
run: python hooks/session_end.py
- id: open-draft-pr
run: bash hooks/open_pr.sh
- id: notify-team-channel
run: bash hooks/notify_slack.sh session_ended
The fail_fast: true flag mirrors Husky’s behavior: if a hook fails, the pipeline aborts. This is the correct default for security-critical hooks like domain allowlisting and SAST scanning.
Why Hooks Are Non-Negotiable
Security
AI agents operate with significant autonomy. Without hooks, there is nothing stopping a compromised prompt from instructing the agent to fetch an attacker-controlled URL, write a backdoor to a source file, or exfiltrate environment variables through an outbound API call.
Pre-fetch hooks enforce network egress policy. Post-edit hooks run secrets scanning. Session-end hooks generate immutable audit trails. Together, they create a security perimeter around the agent’s actions that the agent itself cannot disable.
Reliability
An agent that formats code inconsistently, or skips linting when tired, or forgets to run migrations, is an unreliable teammate. Post-edit hooks run the same quality gates on every file the agent touches, every time, without exception. The agent contributes; the hooks ensure the contribution meets your bar.
Compliance
In regulated industries — finance, healthcare, defense — every change to production systems must be attributable, documented, and reviewable. Session-end hooks that generate structured audit logs turn agent sessions into first-class compliance artifacts. The log answers: who authorized this session, what model was used, which files were modified, which tickets were in scope, and which automated checks passed.
Without hooks, that story has to be reconstructed from scattered logs after the fact — if it can be reconstructed at all.
Practical Rollout Checklist
Shipping a hook layer incrementally reduces risk. The following order works well in practice:
- Week 1 — Deploy session-start and session-end hooks. No blocking behavior; just logging. Build operator confidence in the telemetry.
- Week 2 — Add post-edit formatting hooks in warn-only mode. Review the automatic diff to verify hooks are firing correctly.
- Week 3 — Enable pre-URL fetch domain allowlist. Start with a broad list and tighten based on observed agent traffic.
- Week 4 — Enable SAST scanning in post-edit hooks with
fail_fast: true. Address any false-positive patterns with inline suppression comments. - Ongoing — Add hooks for each new tool the agent gains access to. Treat hooks as the access-control layer for capabilities.
Conclusion
Hooks are not a new idea — they are how mature engineering organizations have enforced policy at process boundaries for decades. What is new is the boundary: the surface where an LLM-based agent intersects with your codebase, your infrastructure, and your data.
Husky taught us that hooks belong in version control. Pre-commit taught us that hook pipelines should be declarative and composable. GitHub Actions taught us that event-driven automation scales from a solo project to an enterprise monorepo. Danger taught us that hooks can express nuanced, context-aware policy.
Apply those lessons to your AI agent layer and you get something powerful: a coding assistant that is not just capable, but predictably safe. The agent handles the creative, exploratory work it is good at. The hooks handle the deterministic, policy-driven enforcement work that should never be left to chance — or to a model.
Start small. Pick one lifecycle event. Write one hook. Ship it. Then repeat.