Extending AI agents with hooks: a complete guide
How to build lifecycle hooks for coding agents — nine events, prompt-based and command-based hooks, a JSON I/O contract, five production patterns, the Hookify rule system, and the composition model that turns agents into programmable platforms.
Most developer tools have extension points. VS Code has extensions. Git has hooks. Chrome has extensions. Coding agents answer with lifecycle events — specific moments where your code can inspect, validate, modify, or block what the agent is about to do. Together they turn an agent from an application into a programmable platform.
Both Claude Code and Codex support hooks. The concepts are shared; the config paths and tool names differ. This guide covers building hooks end-to-end across both platforms.
Where hooks live
Hooks are discovered next to active config layers. They can live as standalone hooks.json files or inline [hooks] tables inside config.toml:
| Location | Scope | Platform |
|---|---|---|
~/.codex/hooks.json / ~/.codex/config.toml | User | Codex |
<repo>/.codex/hooks.json / <repo>/.codex/config.toml | Project | Codex |
~/.claude/hooks.json / ~/.claude/settings.json | User | Claude Code |
<repo>/.claude/hooks.json / <repo>/.claude/settings.json | Project | Claude Code |
Hooks are behind a feature flag on Codex:
[features]
codex_hooks = true If more than one hook source exists, matching hooks accumulate — they don’t replace each other. Multiple command hooks for the same event launch concurrently.
The config shape: events, matchers, handlers
Hooks are organized in three levels:
- Event —
PreToolUse,PostToolUse,Stop, etc. - Matcher group — A regex that filters when the event fires
- Handler — A command or prompt-based hook that runs on match
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 ~/.codex/hooks/session_start.py",
"statusMessage": "Loading session notes"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/pre_tool_use_policy.py",
"statusMessage": "Checking Bash command"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/stop_continue.py",
"timeout": 30
}
]
}
]
}
} Equivalent inline TOML:
[features]
codex_hooks = true
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = 'python3 "$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py"'
timeout = 30
statusMessage = "Checking Bash command" The nine lifecycle events
Every meaningful moment in an agent session has a hook event:
| Event | When it fires | Primary use |
|---|---|---|
| PreToolUse | Before any tool executes | Validate, approve, deny, or modify tool calls |
| PostToolUse | After a tool completes | React to results, provide feedback, log activity |
| Stop | When the main agent considers stopping | Enforce completion standards before exit |
| SubagentStop | When a subagent considers stopping | Ensure subagents completed their task |
| SessionStart | When a session begins | Load project context, detect environment |
| SessionEnd | When a session ends | Cleanup, persist state, audit logging |
| UserPromptSubmit | When the user submits a prompt | Add context, validate input, block forbidden requests |
| PreCompact | Before context compaction | Preserve critical information |
| Notification | When a desktop notification fires | Route to external systems |
The first four operate on agent behavior directly — they can say yes or no. The remaining five operate on session lifecycle — they set up, tear down, and observe.
Two types of hooks: prompt vs. command
Hooks come in two forms, and the choice between them is a tradeoff between flexibility and determinism.
Prompt-based hooks
A prompt-based hook sends a natural language instruction to an LLM alongside the event context. The model decides whether to approve, deny, or warn:
{
"type": "prompt",
"prompt": "Command: $TOOL_INPUT. If the command contains destructive operations like rm, delete, or drop, return 'ask' to confirm with the user. Otherwise return 'approve'.",
"timeout": 30
}
The LLM reads the tool input, applies the criteria, and returns a structured decision. Prompt-based hooks handle ambiguity well — they recognize intent, not just pattern matches. A command that looks dangerous on regex (rm /tmp/cache) is clearly safe to a model that reads the path.
Supported events: PreToolUse, Stop, SubagentStop, UserPromptSubmit
Command hooks
A command hook executes a shell script with deterministic logic:
{
"type": "command",
"command": "python3 .codex/hooks/validate.py",
"timeout": 10
}
The script receives event data as JSON on stdin and decides by exit code. Command hooks are fast, predictable, and auditable — they do exactly one thing and never hallucinate. Use them for checks where correctness matters more than flexibility: path traversal detection, file size limits, secret scanning.
Default timeouts: Vary by platform. Codex defaults to 600 seconds for command hooks. Claude Code defaults to 60 seconds for command hooks, 30 seconds for prompt hooks. Set lower timeouts for hot-path checks, higher for batch operations.
The JSON I/O contract
Every command hook receives one JSON object on stdin. Shared fields across all events:
| Field | Type | Meaning |
|---|---|---|
session_id | string | Current session or thread id |
transcript_path | string | null | Path to session transcript file |
cwd | string | Working directory for the session |
hook_event_name | string | Current hook event name |
model | string | Active model slug |
Turn-scoped hooks also receive turn_id. Event-specific fields vary:
- PreToolUse / PostToolUse:
tool_name,tool_input(andtool_resultfor PostToolUse) - UserPromptSubmit:
prompttext (Claude Code:user_prompt) - Stop / SubagentStop:
last_assistant_message
Inside prompt-based hooks, these are accessible as $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT. Command hooks parse them from stdin JSON.
Output format
Hooks return a JSON decision on stdout:
{
"continue": true,
"suppressOutput": false,
"systemMessage": "File write validated and approved"
}
For PreToolUse specifically, the output includes a permission decision:
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"updatedInput": {"file_path": "/corrected/path.ts"}
},
"systemMessage": "Path corrected to match project conventions"
}
The permissionDecision field takes three values: allow (proceed normally), deny (block silently with an error), or ask (surface to the user for manual approval). The updatedInput field lets hooks modify tool inputs before execution — correct a path, add a flag, strip dangerous arguments.
Exit codes
- Exit 0 with no output — success, agent continues.
- Exit 0 with JSON — agent processes the output fields.
- Exit 2 — blocking error. For Stop hooks, this tells the agent to continue working. For PreToolUse, it blocks the tool call. stderr is fed back as feedback.
Matcher patterns
The matcher field is a regex string that scopes hooks to specific tools or session sources. Use "*", "", or omit it to match every occurrence:
| Event | What matcher filters | Examples |
|---|---|---|
PreToolUse | Tool name | Bash, ^apply_patch$, Edit|Write, mcp__filesystem__.* |
PostToolUse | Tool name | Same as PreToolUse |
PermissionRequest | Tool name | Same as PreToolUse |
SessionStart | Start source | startup, resume, clear |
UserPromptSubmit | Not supported | Any matcher is ignored |
Stop | Not supported | Any matcher is ignored |
Matchers support exact tool names ("Write"), alternation ("Read|Write|Edit"), wildcards ("*"), and full regex ("mcp__.*__delete.*" for all MCP delete operations).
Multiple hooks on the same matcher run in parallel. This forces each hook to be self-contained — hooks can’t depend on each other’s output. A PreToolUse with three hooks (check size, check path, check content) runs all three simultaneously and blocks only if any one returns deny.
Pattern 1: Block destructive commands
The most common hook. A PreToolUse hook inspects Bash commands and blocks anything destructive:
import json
import sys
DANGEROUS = {"rm", "sudo", "chmod", "chown", "mkfs", "dd", ">", "| sh"}
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
for pattern in DANGEROUS:
if pattern in command:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked: '{pattern}' in command."
}
}))
sys.exit(0)
sys.exit(0) The hook receives the full tool input, checks for dangerous patterns, and returns a structured deny decision. The agent surfaces the reason to the user. The command never executes.
Pattern 2: Enforce completion standards with Stop hooks
Stop hooks fire when the agent thinks it’s done. Return a continuation decision to keep it working:
import json
import sys
data = json.load(sys.stdin)
last_message = data.get("last_assistant_message", "") or ""
checks = {
"tests_passing": "all tests pass" in last_message.lower(),
"lint_clean": "lint" in last_message.lower(),
"no_todos": "TODO" not in last_message,
}
if not all(checks.values()):
missing = [k for k, v in checks.items() if not v]
reason = f"Before stopping: verify {', '.join(missing)}."
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(0)
sys.exit(0) When the hook returns decision: "block", the agent creates a new continuation prompt using your reason text. The agent doesn’t stop — it addresses the missing checks.
Pattern 3: Inject context on SessionStart
SessionStart hooks can inject additional context into every new session:
import json
import sys
import os
data = json.load(sys.stdin)
source = data.get("source", "startup")
if source == "startup":
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Load workspace conventions before editing."
}
})) Plain text on stdout also works — it’s added as extra developer context. Use this for loading project-specific instructions, environment detection, or setting session-level variables.
Pattern 4: Audit logging with PostToolUse
PostToolUse runs after every supported tool completes. Use it for audit trails:
import json
import sys
from datetime import datetime
data = json.load(sys.stdin)
tool = data.get("tool_name", "unknown")
command = data.get("tool_input", {}).get("command", "")[:200]
with open("/var/log/agent-audit.log", "a") as f:
f.write(json.dumps({
"timestamp": datetime.now().isoformat(),
"session": data.get("session_id"),
"tool": tool,
"command": command,
}) + "\n")
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Command logged for audit."
}
})) PostToolUse can also block further processing with continue: false — the tool result is replaced with your feedback, and the model continues from there.
Pattern 5: The Ralph Wiggum loop
The most dramatic use of Stop hooks is the self-referential agent loop — named after the relentlessly persistent Simpsons character. A Stop hook that blocks exit and feeds the same prompt back creates an autonomous iteration loop:
#!/bin/bash
# ralph-loop.sh
PROMPT_FILE="$PROJECT_DIR/.ralph-prompt.txt"
COMPLETION_PROMISE="${COMPLETION_PROMISE:-DONE}"
if grep -q "$COMPLETION_PROMISE" "$TRANSCRIPT_PATH" 2>/dev/null; then
echo '{"decision": "approve"}'
exit 0
fi
echo "{\"decision\": \"block\", \"reason\": \"Work not complete. Continue iterating.\"}" >&2
exit 2
The loop:
- You write a prompt describing the task and a completion promise (“Output
<promise>COMPLETE</promise>when done”) - The agent works on the task
- The agent tries to exit
- The Stop hook inspects the transcript, finds no completion promise, and blocks exit (code 2)
- The agent receives the block as feedback and continues working
- Repeat until the completion promise appears or the iteration cap is hit
The prompt never changes between iterations. The agent’s previous work persists in files on disk. Each iteration, the agent reads its own past work, sees what tests pass or fail, and improves.
Key safeguards: always set --max-iterations as a ceiling, include a completion promise so the loop has a defined exit condition, and design prompts for incremental progress (phases, checklists, TDD loops) rather than monolithic goals.
Hookify: rules as configuration, not code
Writing hooks typically means writing JSON configuration and shell scripts. The Hookify plugin removes that friction: describe the behavior and it generates a markdown configuration file with YAML frontmatter. No code, no JSON, no shell scripts.
A rule is a single markdown file:
---
name: block-dangerous-rm
enabled: true
event: bash
pattern: rm\s+-rf
action: block
---
🛑 **Dangerous rm command detected!**
This command could delete important files. Verify the path is correct
and consider using a safer approach.
action: block prevents the operation entirely. action: warn (the default) shows the message but allows the operation to continue.
Advanced rules with conditions
Multi-condition rules check several fields simultaneously:
---
name: api-key-in-typescript
enabled: true
event: file
action: warn
conditions:
- field: file_path
operator: regex_match
pattern: \.tsx?$
- field: new_text
operator: regex_match
pattern: (API_KEY|SECRET|TOKEN)\s*=\s*["']
---
🔐 **Hardcoded credential in TypeScript!**
Use environment variables instead of hardcoded values.
All conditions must match for the rule to fire. This lets you write targeted rules: “warn about hardcoded credentials, but only in TypeScript files, and only when they appear in new content being written.”
The six operators
| Operator | Description | Example pattern |
|---|---|---|
regex_match | Full regex matching | \.env$|credentials|secrets |
contains | Substring anywhere | console.log( |
equals | Exact string match | production |
not_contains | Absence check | npm test|pytest|cargo test |
starts_with | Prefix match | DROP TABLE |
ends_with | Suffix match | .env |
The Hookify format has zero dependencies — it parses YAML frontmatter with Python’s stdlib. Rules live as plain files you can grep, diff, and version. No registry, no build step, no server. Adding a rule is creating a file; removing a rule is deleting it. Rules take effect immediately without restart.
Managed hooks for enterprise
Enterprise admins can enforce hooks through requirements.toml:
[features]
codex_hooks = true
[hooks]
managed_dir = "/enterprise/hooks"
windows_managed_dir = 'C:\enterprise\hooks'
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/hooks/pre_tool_use_policy.py"
timeout = 30
statusMessage = "Checking managed Bash command" Managed hooks use absolute script paths. Your enterprise tooling (MDM, configuration management) must install and update the scripts separately.
The composition model
Individually, each hook event solves a narrow problem. Composed, they form a programmable surface:
Layered validation. A SessionStart hook detects the project type and sets environment variables. PreToolUse hooks validate every file write (no path traversal, no secrets, correct project conventions). PostToolUse hooks run linters on changed files. A Stop hook blocks exit unless tests pass and the build succeeded.
Cross-event workflows. SessionStart initializes tracking counters. PostToolUse increments them when specific tools run. Stop reads the counters and blocks if required operations never happened.
External integration. A Notification hook routes every desktop notification to Slack. A SessionEnd hook writes an audit log entry. A PreToolUse hook on destructive MCP operations posts a confirmation request to a webhook.
Policy enforcement at scale. Enterprise-managed hooks deployed across an organization block specific behaviors regardless of user settings: no writes to /etc, no curl | bash, no hardcoded credentials, tests must pass before any session ends.
| Event | Architecture role |
|---|---|
| PreToolUse | Guard — validate, block, or modify before action |
| PostToolUse | React — enforce quality, provide feedback after action |
| Stop | Gate — prevent exit until standards are met |
| SessionStart | Bootstrap — configure environment before work begins |
| SessionEnd | Cleanup — persist state, audit, notify on close |
| UserPromptSubmit | Intercept — add context or block prompts before processing |
| PreCompact | Preserve — save critical context before truncation |
| Notification | Observe — route events to external systems |
Timeout and performance
Default timeouts vary by platform. Tune them per hook:
- PreToolUse on every Bash command → 10-30 seconds. The hook should be fast; it runs before every shell command.
- Stop hook with transcript review → 30-60 seconds. It runs once per turn end.
- PostToolUse audit logging → 5-10 seconds. Fire and forget.
For repo-local hooks, resolve paths from the git root instead of relative paths:
command = 'python3 "$(git rev-parse --show-toplevel)/.codex/hooks/check.py"'
This keeps hooks stable regardless of which subdirectory the agent starts from.
Hooks turn the agent into a platform
Nine lifecycle events let you intercept, validate, and extend agent behavior at every meaningful moment — from session start through tool execution to stop decisions.
Prompt hooks handle judgment; command hooks handle determinism
Use prompt hooks when the decision requires reading intent and context. Use command hooks when correctness matters more than flexibility.
The JSON contract is the extension protocol
Stdin JSON in, stdout JSON out, exit code 2 for block. Every hook speaks the same protocol regardless of event type or platform.
Stop hooks create self-correcting agents
Return decision: "block" from a Stop hook and the agent continues working with your reason as the new prompt. The Ralph Wiggum pattern turns this into autonomous iteration.
Hookify removes the code barrier
Six operators, YAML frontmatter, zero dependencies. Rules take effect immediately without restart. Describe the behavior, not the implementation.
Composition makes it a platform
Individual hooks solve narrow problems. Layered — SessionStart detection + PreToolUse validation + Stop gating — they form a programmable surface.
Combine hooks with sandboxing
PreToolUse blocks specific commands but doesn't cover all tool paths. Sandbox policies, approval settings, and hooks are complementary layers.