All articles
platform engineering · advanced ·

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.

claude-codecodexcoding-agentsextensibilityhookspatterns

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:

Hook locations by platform
LocationScopePlatform
~/.codex/hooks.json / ~/.codex/config.tomlUserCodex
<repo>/.codex/hooks.json / <repo>/.codex/config.tomlProjectCodex
~/.claude/hooks.json / ~/.claude/settings.jsonUserClaude Code
<repo>/.claude/hooks.json / <repo>/.claude/settings.jsonProjectClaude Code

Hooks are behind a feature flag on Codex:

~/.codex/config.toml toml
[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:

  1. EventPreToolUse, PostToolUse, Stop, etc.
  2. Matcher group — A regex that filters when the event fires
  3. Handler — A command or prompt-based hook that runs on match
~/.codex/hooks.json json
{
  "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:

~/.codex/config.toml 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:

Hook events
EventWhen it firesPrimary use
PreToolUseBefore any tool executesValidate, approve, deny, or modify tool calls
PostToolUseAfter a tool completesReact to results, provide feedback, log activity
StopWhen the main agent considers stoppingEnforce completion standards before exit
SubagentStopWhen a subagent considers stoppingEnsure subagents completed their task
SessionStartWhen a session beginsLoad project context, detect environment
SessionEndWhen a session endsCleanup, persist state, audit logging
UserPromptSubmitWhen the user submits a promptAdd context, validate input, block forbidden requests
PreCompactBefore context compactionPreserve critical information
NotificationWhen a desktop notification firesRoute 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:

Common input fields
FieldTypeMeaning
session_idstringCurrent session or thread id
transcript_pathstring | nullPath to session transcript file
cwdstringWorking directory for the session
hook_event_namestringCurrent hook event name
modelstringActive model slug

Turn-scoped hooks also receive turn_id. Event-specific fields vary:

  • PreToolUse / PostToolUse: tool_name, tool_input (and tool_result for PostToolUse)
  • UserPromptSubmit: prompt text (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:

Matcher support by event
EventWhat matcher filtersExamples
PreToolUseTool nameBash, ^apply_patch$, Edit|Write, mcp__filesystem__.*
PostToolUseTool nameSame as PreToolUse
PermissionRequestTool nameSame as PreToolUse
SessionStartStart sourcestartup, resume, clear
UserPromptSubmitNot supportedAny matcher is ignored
StopNot supportedAny 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:

.codex/hooks/pre_tool_use_policy.py python
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:

.codex/hooks/stop_continue.py python
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:

.codex/hooks/session_start.py python
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:

.codex/hooks/post_tool_use_review.py python
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:

  1. You write a prompt describing the task and a completion promise (“Output <promise>COMPLETE</promise> when done”)
  2. The agent works on the task
  3. The agent tries to exit
  4. The Stop hook inspects the transcript, finds no completion promise, and blocks exit (code 2)
  5. The agent receives the block as feedback and continues working
  6. 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

Hookify rule operators
OperatorDescriptionExample pattern
regex_matchFull regex matching\.env$|credentials|secrets
containsSubstring anywhereconsole.log(
equalsExact string matchproduction
not_containsAbsence checknpm test|pytest|cargo test
starts_withPrefix matchDROP TABLE
ends_withSuffix 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:

requirements.toml 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.

Hook events mapped to architecture role
EventArchitecture role
PreToolUseGuard — validate, block, or modify before action
PostToolUseReact — enforce quality, provide feedback after action
StopGate — prevent exit until standards are met
SessionStartBootstrap — configure environment before work begins
SessionEndCleanup — persist state, audit, notify on close
UserPromptSubmitIntercept — add context or block prompts before processing
PreCompactPreserve — save critical context before truncation
NotificationObserve — 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.