Codex CLI Adapter
Native event names: PascalCase (Claude-like). Reference doc: https://developers.openai.com/codex/hooks.
Codex shares Claude's response envelope (hookSpecificOutput.permissionDecision, additionalContext) and exit-code semantics. Differences from Claude: extra context fields (turn_id, tool_use_id, last_assistant_message) and the apply_patch tool name for file edits.
Stdin → unified
Codex hook_event_name | Unified |
|---|---|
SessionStart | SessionStart (preserves source, lifts model) |
PreToolUse | PreToolUse (tool aliased; native_tool preserves the original; _native keeps turn_id, tool_use_id) |
PostToolUse | PostToolUse (lifts model, native_tool) |
UserPromptSubmit | UserPromptSubmit (lifts model) |
Stop | Stop (preserves stop_hook_active; lifts last_assistant_message and model) |
Codex also emits PermissionRequest as a separate permission-gating event with its own response shape (hookSpecificOutput.decision.behavior: "allow" | "deny" plus optional decision.message). The adapter folds it into the unified PreToolUse handler so you write one handler that covers both. On the wire, the bridge serializes the response as PermissionRequest's nested shape when the original event was PermissionRequest. To distinguish in your handler, read event._native.hook_event_name. Codex's PermissionRequest does not accept decision: "ask"; passing it logs a warning and falls back to allow.
apply_patch aliasing
Codex uses one tool name for all file modifications:
{
"tool_name": "apply_patch",
"tool_input": {
"patch": "..."
}
}The adapter aliases this to tool: "Edit":
defineHook({
PreToolUse: (event) => {
if (event.tool === "Edit") {
// works on Claude (tool_name=Edit), Codex (apply_patch), Cursor (afterFileEdit/preToolUse)
}
},
});The unified event.tool === "Write" does not fire for Codex, even if the patch creates a brand-new file: Codex emits one tool name (apply_patch) for both creates and updates, and the bridge collapses both to Edit. Two ways to disambiguate:
- Read
event.native_tool(always set; equals the host's original tool name, e.g."apply_patch"for Codex,"Edit"or"Write"for Claude). - Parse
event.tool_input.patchand look for*** Add File:(create) vs*** Update File:(update).
Other tool names pass through unchanged.
Unified → stdout
| Unified field | Codex wire mapping |
|---|---|
decision on PreToolUse | hookSpecificOutput.permissionDecision + exit 2 if deny |
reason on PreToolUse | hookSpecificOutput.permissionDecisionReason |
decision: "deny" on others | top-level decision: "block" + reason + exit 2 |
additional_context on context-injection events | hookSpecificOutput.additionalContext |
Dropped fields
user_message: Codex has no human-facing channel; warn + dropmodified_input: not wired on Codex; warn + drop
Round-trip example
Stdin (note turn_id + apply_patch):
{
"hook_event_name": "PreToolUse",
"session_id": "sess-codex",
"cwd": "/Users/x/proj",
"model": "gpt-5",
"turn_id": "turn-001",
"tool_use_id": "tu-001",
"tool_name": "apply_patch",
"tool_input": {
"patch": "*** Begin Patch..."
}
}The handler from Getting Started checks tool === "Bash"; apply_patch aliases to Edit, not Bash, so the response is:
{
decision: "allow"
}Stdout (consumed by Codex):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}exit code: 0
Generated config
.codex/hooks.toml (TOML, not JSON):
# >>> agent-hooks-bridge: managed block; do not edit between markers
[[hooks]]
event = "SessionStart"
command = "./.hooks/format.ts --host codex"
[[hooks]]
event = "PreToolUse"
command = "./.hooks/format.ts --host codex"
matcher = "*"
[[hooks]]
event = "PostToolUse"
command = "./.hooks/format.ts --host codex"
matcher = "*"
[[hooks]]
event = "UserPromptSubmit"
command = "./.hooks/format.ts --host codex"
[[hooks]]
event = "Stop"
command = "./.hooks/format.ts --host codex"
# <<< agent-hooks-bridgeRe-running install replaces the block between the markers in place. Anything outside the markers (your existing TOML config) is preserved.