Codex CLI Hooks Engine: Extending the Agentic Loop with Lifecycle Scripts

Codex CLI Hooks Engine: Extending the Agentic Loop with Lifecycle Scripts
The Codex CLI Hooks Engine, introduced experimentally in v0.114.01, gives developers a principled way to inject scripts into the agentic loop at defined lifecycle points. Before hooks, the only way to enforce policy was to fork the process and manually parse JSONL rollout files — a fragile approach that broke across releases. Hooks change this: they are a first-class, documented mechanism backed by a stable JSON protocol.
This article covers the complete hooks surface as of v0.117.0: the five supported events, the hooks.json configuration format, the stdin/stdout contract, exit-code semantics, and practical patterns for security gates, audit logging, and session initialisation.
Enabling Hooks
Hooks are off by default.2 Enable them in ~/.codex/config.toml:
[features]
codex_hooks = true
Windows support is temporarily disabled.3 macOS and Linux are fully supported.
Hook File Discovery
Codex loads hooks.json from two locations. Both files are loaded and their hooks merged — higher-precedence files do not shadow lower-precedence ones:4
| Location | Scope |
|---|---|
~/.codex/hooks.json |
User-wide defaults |
<repo>/.codex/hooks.json |
Repository-level overrides |
This mirrors the layered semantics of config.toml and AGENTS.md: global defaults, local overrides.
The Five Hook Events
Codex currently supports five lifecycle events.5 Four are turn-scoped (they fire within a single agent turn); SessionStart is session-scoped.
sequenceDiagram
participant User
participant Codex
participant Hook
User->>Codex: Session begins
Codex->>Hook: SessionStart
Hook-->>Codex: additionalContext (optional)
loop Each Turn
User->>Codex: Submit prompt
Codex->>Hook: UserPromptSubmit
Hook-->>Codex: continue / block
Codex->>Hook: PreToolUse (Bash)
Hook-->>Codex: allow / deny
Note over Codex: Bash executes
Codex->>Hook: PostToolUse (Bash)
Hook-->>Codex: systemMessage / additionalContext
Codex->>Hook: Stop
Hook-->>Codex: continue / auto-continue with new prompt
end
SessionStart
Fires when a session begins (startup) or is resumed (resume). The matcher filters on start source:6
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "python3 ~/.codex/hooks/load_workspace_notes.py",
"statusMessage": "Loading workspace context"
}
]
}
]
}
}
stdin payload:
{
"session_id": "ses_abc123",
"hook_event_name": "SessionStart",
"cwd": "/home/dan/projects/myapp",
"model": "gpt-5.3-codex",
"transcript_path": "/home/dan/.codex/sessions/ses_abc123.jsonl"
}
stdout response (optional): Plain text written to stdout is injected as developer context — extra system instructions prepended to the conversation.7 Useful for loading dynamic workspace notes or environment-specific policies.
#!/usr/bin/env python3
import json, sys, subprocess
# Inject current git branch and recent commits as context
branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode().strip()
log = subprocess.check_output(["git", "log", "--oneline", "-5"]).decode().strip()
print(f"Current branch: {branch}\nRecent commits:\n{log}")
PreToolUse
Fires before Codex executes a Bash command. This is the most security-critical hook — it can block the command before any side effects occur.8 Currently limited to Bash tool calls; other tools are not yet surfaced.
stdin payload:
{
"session_id": "ses_abc123",
"hook_event_name": "PreToolUse",
"turn_id": "turn_42",
"cwd": "/home/dan/projects/myapp",
"model": "gpt-5.3-codex",
"tool_name": "Bash",
"tool_use_id": "tool_use_7",
"tool_input": {
"command": "rm -rf ./dist"
}
}
Blocking a command: Return exit code 2 and write the reason to stderr, or write a JSON block decision to stdout:9
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive rm -rf blocked by hook policy."
}
}
Example — blocking destructive commands:
#!/usr/bin/env python3
import json, sys, re
payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
BLOCKED_PATTERNS = [
r"\brm\s+-rf\b",
r"\bgit\s+push\s+--force\b",
r"\bdrop\s+table\b",
r"\btruncate\s+table\b",
]
for pattern in BLOCKED_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Command matches blocked pattern: {pattern}"
}
}))
sys.exit(2)
⚠️
PreToolUsecurrently supportssystemMessagein the response, butcontinue,stopReason, andsuppressOutputare parsed but not yet implemented for this event.10
PostToolUse
Fires after a Bash command completes. Cannot undo side effects, but can inject feedback into the model’s context or signal an error condition.11
stdin payload adds the tool response:
{
"session_id": "ses_abc123",
"hook_event_name": "PostToolUse",
"turn_id": "turn_42",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_response": {
"output": "...",
"exit_code": 1
}
}
Useful response fields: systemMessage is surfaced as a UI warning; continue: false with stopReason ends the turn; additionalContext feeds structured information back to the model.12
Example — test failure audit log:
#!/usr/bin/env python3
import json, sys, datetime, pathlib
payload = json.load(sys.stdin)
response = payload.get("tool_response", {})
exit_code = response.get("exit_code", 0)
if exit_code != 0:
log_entry = {
"ts": datetime.datetime.utcnow().isoformat(),
"session_id": payload["session_id"],
"command": payload["tool_input"]["command"],
"exit_code": exit_code,
}
log_path = pathlib.Path.home() / ".codex" / "audit.jsonl"
with log_path.open("a") as f:
f.write(json.dumps(log_entry) + "\n")
UserPromptSubmit
Fires every time the user submits a prompt, before it enters the conversation history. Ideal for secret detection — catching accidentally pasted API keys or tokens before they persist.13
The matcher field is silently ignored for this event: all UserPromptSubmit hooks receive every prompt.14
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/usr/local/bin/python3 ~/.codex/hooks/secret_scan.py"
}
]
}
]
}
}
stdin payload:
{
"session_id": "ses_abc123",
"hook_event_name": "UserPromptSubmit",
"turn_id": "turn_43",
"prompt": "Please update the config to use sk-live-XXXXXXXXXXXXXXXXXXXXXXXX"
}
Example — secret scanning hook:
#!/usr/bin/env python3
import json, sys, re
SECRET_PATTERNS = [
(r"sk-live-[A-Za-z0-9]{20,}", "OpenAI live API key"),
(r"ghp_[A-Za-z0-9]{36}", "GitHub personal access token"),
(r"AKIA[0-9A-Z]{16}", "AWS access key ID"),
(r"-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----", "Private key material"),
]
payload = json.load(sys.stdin)
prompt = payload.get("prompt", "")
for pattern, label in SECRET_PATTERNS:
if re.search(pattern, prompt):
print(json.dumps({
"continue": False,
"stopReason": f"Blocked: prompt contains {label}. Remove the secret and try again."
}))
sys.exit(2)
Stop
Fires at the end of each turn, after the assistant’s final message. Uniquely, returning decision: "block" does not abort the session — it triggers an auto-continuation, appending the reason as a new user prompt.15 This enables self-directed loops: a hook can instruct Codex to verify its own output.
stdin payload includes the final assistant message:
{
"session_id": "ses_abc123",
"hook_event_name": "Stop",
"turn_id": "turn_42",
"stop_hook_active": false,
"last_assistant_message": "I've updated the function. Here are the changes..."
}
⚠️ The
stop_hook_activeflag prevents infinite loops: ifStopfires because a previousStophook triggered a continuation, this flag istrue. Your hook must check it and return normally to avoid infinite recursion.16
Example — automatic test verification loop:
#!/usr/bin/env python3
import json, sys, subprocess
payload = json.load(sys.stdin)
# Prevent recursive continuation
if payload.get("stop_hook_active"):
sys.exit(0)
result = subprocess.run(
["npm", "test", "--silent"],
capture_output=True, cwd=payload["cwd"]
)
if result.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": f"Tests failed after your changes. Failures:\n{result.stdout.decode()[:2000]}\nPlease fix them."
}))
sys.exit(2)
Exit Code Semantics
| Exit Code | Meaning |
|---|---|
0 |
Success — Codex proceeds normally |
1 |
Non-blocking error — Codex logs but proceeds |
2 |
Block — action is denied or turn is stopped |
Exit code 2 is the enforcement code. Any security gate that does not return 2 on match is advisory only.
Timeout Configuration
Every hook definition accepts a timeout field (seconds, default 600).17 Keep synchronous hooks fast:
{
"type": "command",
"command": "python3 ~/.codex/hooks/quick_check.py",
"statusMessage": "Checking policy",
"timeout": 10
}
Hooks running under 200ms add no noticeable latency. Slow PreToolUse hooks block every Bash call — profile carefully.
Concurrency
Multiple hooks matching the same event fire concurrently.18 One hook cannot prevent another from starting. For security gates, this means multiple blockers can all fire simultaneously, but if any returns exit code 2, the action is blocked.
Community Ecosystem
The hatayama/codex-hooks project provides a macOS-focused hook runner that watches session JSONL files and replays a compatible subset of Claude Code hook events (TaskStarted, TaskComplete, TurnAborted). It also checks ~/.codex/hooks.json before falling back to Claude’s ~/.claude/settings.json, enabling teams running both agents to share hook definitions.19
Note the important limitation: this runner executes commands but does not implement the full Codex runtime protocol — hooks returning {"decision":"block"} will not influence Codex behaviour through this runner. It is best suited for side-effect notifications (desktop alerts, iTerm title updates, sound effects) rather than policy enforcement.
Limitations and Roadmap
Several planned hook features are not yet implemented:20
updatedInput— modify the tool input before executionupdatedMCPToolOutput— rewrite MCP tool resultssuppressOutput— parsed but inactivepermissionDecision: "ask"— interactive approval mid-hookPreToolUse/PostToolUsefor non-Bash tools (Edit, Write, etc.)- Windows support
The gap between Codex hooks (5 events) and Claude Code hooks (12+ events) is most visible in the absence of MCP tool interception and non-Bash tool lifecycle events — worth tracking in the GitHub issues.21
Practical Configuration: Full Example
A production-grade .codex/hooks.json combining all five events:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/load_context.py",
"statusMessage": "Loading workspace context",
"timeout": 15
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/secret_scan.py",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/command_policy.py",
"statusMessage": "Checking command policy",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/audit_log.py",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 .codex/hooks/verify_tests.py",
"timeout": 120
}
]
}
]
}
}
This stack gives you: dynamic context injection at startup, secret scanning on every prompt, destructive command blocking, a JSONL audit trail of all commands, and automatic test verification before each turn ends.
Citations
-
Codex CLI v0.114.0 release — experimental hooks with
SessionStartandStopevents (PR #13276). GitHub Releases: https://github.com/openai/codex/releases ↩ -
features.codex_hooksconfiguration key. Codex Configuration Reference: https://developers.openai.com/codex/config-reference ↩ -
Hooks — Codex Developer Documentation (Windows support note): https://developers.openai.com/codex/hooks ↩
-
Hook file discovery and merge behaviour. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
Five supported hook events: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Stop. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
SessionStart matcher values (
startup,resume). Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
additionalContextfield in SessionStart hookSpecificOutput. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
PreToolUse as security gate — fires before command execution. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
Blocking with exit code 2 or
permissionDecision: "deny". Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
PreToolUse unsupported fields:
continue,stopReason,suppressOutput. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
PostToolUse cannot undo side effects. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
PostToolUse supported fields:
systemMessage,continue: false,stopReason. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
UserPromptSubmit for secret detection. OpenAI Codex CLI ships v0.116.0 with enterprise features — Augment Code: https://www.augmentcode.com/learn/openai-codex-cli-enterprise ↩
-
UserPromptSubmit matcher is silently ignored. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
Stop hook
decision: "block"triggers auto-continuation. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
stop_hook_activeflag prevents infinite loops. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
Hook
timeoutparameter, default 600 seconds. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩ -
Concurrent hook execution. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
hatayama/codex-hooks— Claude Code hook compatibility runner: https://github.com/hatayama/codex-hooks ↩ -
Unimplemented hook features. Codex Hooks Documentation: https://developers.openai.com/codex/hooks ↩
-
Community request for PreToolUse/PostToolUse for non-Bash tools — Issue #14754: https://github.com/openai/codex/issues/14754 ↩