What a hook can change is set by the event contract
One turn walks eleven hooks. The protocol names four handler types; the runtime table only loads commands and MCP. Timeouts fail open. Teardown drops stdout.
SessionEnd never reads output. A hook point is a reserved socket on the lifecycle. What can change the next step is the event contract, not the name in a config file.
- Protocol enum lists eleven event namesprotocol.rs L1510
- Config accepts four types; the last two are empty structshook_config.rs L183
- Discovery writes Prompt and Agent as not supported yetdiscovery.rs L626
- Runtime table only accepts Command and McpToolengine/mod.rs L107
- Only a sync hook can apply a control effectengine/mod.rs L146
- Timeout writes error; should_block stays falsecommand_runner.rs L317
- Exit code 2 plus stderr is what marks Blockedpre_tool_use.rs L261
- Allow maps to a one-shot Approvedapprovals.rs L465
- A Stop block only continues at the turn layer if it carries a promptturn.rs L509
- SessionEnd treats exit 0 as done and drops stdoutsession_end.rs L109
You just moved a hooks.json over from Claude Code. Three handlers sit in the file: a command that blocks a dangerous shell, a prompt that lets a small model review the user submit, and an agent that starts a child session at Stop to run a linter. On Claude, all three run.
Paste it into Codex and start a session. The command lights up. The other two each log not supported yet. The protocol enum clearly lists Prompt and Agent, and config parsing accepts both tags. What lands in the runtime table is only commands and MCP tools.
The kernel shows the outside three tables, and they are not the same width.
The protocol face lists eleven event names; serde uses snake_case. Four handler types sit beside them: Command, McpTool, Prompt, Agent.
Source:codex-rs/protocol/src/protocol.rs lines 1508–1531
The config face catches all four names with a type tag. The last two are empty structs. Parsing succeeds; the fields hold nothing executable.
Source:codex-rs/config/src/hook_config.rs lines 183–187
Discovery sees those last two and continues, with the copy prompt hooks are not supported yet and agent hooks are not supported yet. Inside the engine, the only executable kinds are commands and MCP tools. In ordinary user config they only become warnings, and the session continues. A managed required hook that names them fails startup.
Source:codex-rs/hooks/src/engine/discovery.rs lines 626–645
The JSON hook runtime type is literally ClaudeHooksEngine. Compatibility is not a wish in a comment — it is the type name. stdin is fed JSON; stdout is parsed against a schema. An old notify path still sits beside it, fire-and-forget one command when a turn clocks out. Don’t mix the two roads.
Source:codex-rs/hooks/src/engine/mod.rs lines 107–119
The config face can be wider than the executor. Catch the four types already in the ecosystem first, so an unknown tag does not blow up the whole hooks.json. The cost: people who migrate will read capability off the enum names. So discovery must leave stable copy, and a required policy that hits an unimplemented type must refuse to start. Rewrite it in another language and the minimum shape is still two tables plus a skip.
Someone wrote a PreToolUse script, set timeout to 1 second, and slept 5 seconds inside. They thought a crashed hook meant a block. The tool still ran. In the log that hook is failed, with copy timed out after 1s.
run_command on timeout writes error as hook timed out after {n}s, and exit_code is empty. Parsing sees error and only marks Failed; should_block stays the default false. The tool registry then continues into handle_any_tool.
Source:codex-rs/hooks/src/engine/command_runner.rs lines 317–326
Only two roads actually block. JSON gives deny or block. Or exit code 2 with nonempty stderr. Exit code 2 with no reason counts as failure, not a block. An async hook that returns deny still cannot apply a control effect. Only a sync, trusted, not-timed-out handler can change the next step.
Source:codex-rs/hooks/src/events/pre_tool_use.rs lines 261–277
On the approval path the rule flips. PermissionRequest runs before Guardian and the user approval UI. It does not rewrite tool input. Fold: any deny wins at once; otherwise keep the last allow. Allow maps to a one-shot Approved and does not enter the session cache. The same command next time gets asked again.
Source:codex-rs/core/src/tools/approvals.rs lines 454–474
Claude’s PermissionRequest output has updatedInput. Codex marks that field reserved and fail-closes if it sees it. Move a rewrite approval hook over from Claude as-is, and it fails here — it will not rewrite.
A crashed linter hook should not freeze every tool. Availability sits in front of intercept reliability. To change the flow you must be sync, and you must have an explicit decision. To send a notice you can be async, at most eight in parallel. The approval path fail-closes on ambiguous output, because that layer cannot treat an unread field as allow.
PostToolUse wants to block a dangerous write — the file is already on disk. SessionEnd wants to stuff a wrap-up into context — stdout is dropped. The wire enum does not even have that event name. Both wrecks share one fact: the hook point has already walked the stretch it could change.
Eleven hook points line up on the lifecycle. The main axis is SessionStart → UserPromptSubmit → PreToolUse → PermissionRequest → tool → PostToolUse → PreCompact → PostCompact → Stop → SessionEnd. Sub-agents walk a thinner axis; SubagentStart and SubagentStop only fire on ThreadSpawn.
Each point has a different contract.
PreToolUse can block a tool and rewrite input. Failures fail open. PostToolUse only runs after a successful tool; a block rejects the result, and the side effect already happened. A Stop block that carries a continuation prompt is what continues at the turn layer, without re-emitting TurnStarted. A block with no prompt is ignored. should_stop is what hands control back to the task shell. stop beats block.
Source:codex-rs/core/src/session/turn.rs lines 509–538
A UserPromptSubmit block is written as should_stop: it stops this user message, and does not take stderr as the next-round prompt. matcher is ignored here. SessionStart honors continue: false. SubagentStart only injects context; the same field is dropped.
SessionEnd is a teardown notice. Timeout defaults to 1 second, cap 3, to leave slack for app-server’s five-second shutdown. Exit 0 means done; the whole stdout is dropped. The MCP shape is skipped outright. reason is currently hardcoded other.
Source:codex-rs/hooks/src/events/session_end.rs lines 20–24
Hook text becomes a developer-role snippet before it reaches the model. Default budget is about 2500 approximate tokens. Over the cap, the full text is written to a temp directory; the model sees a head-and-tail preview plus one path line. If the write fails, it is only truncated.
Source:codex-rs/hooks/src/output_spill.rs lines 53–91
A hook’s power is bound to where it sits on the lifecycle. Before the tool runs, you can rewrite input or skip. After it ran, you can only change the slice the model sees. Teardown has a few seconds; reading JSON, pouring context back, then waiting on MCP would drag shutdown past the cap. So it becomes a pure notice. Swap the runtime and the question is still: is this point still in time to change what already happened?
Claude Code: twenty-seven events, and Prompt and Agent actually run
In the restored source, HOOK_EVENTS has 27 entries. Codex’s eleven are all there, plus post-failure events, notifications, worktree and file changes. execPromptHook runs a prompt on a small model, and when it builds the user message it bypasses processUserInput so UserPromptSubmit does not fire again. execAgentHook starts a full query.
Claude’s runtime face is wider than its config face. Codex flips that: catch four types first, fill executors later. Event names overlap heavily — that is the alignment move. The type name ClaudeHooksEngine writes the product judgment into an identifier.
DSH: the plugin waterfall is native; the hook file is a compatibility bridge
DSH’s tool pipeline opens a waterfall on tools/pre-execute and another on tools/post-execute. A native plugin can do everything the bridge can. hooks-codex only maps five points: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop. No rewrite, no PermissionRequest. Anything other than type: command, and anything async, is skipped after parse.
DSH had plugins first, then added file compatibility. Codex had the Claude file contract first, then lets plugins stuff declarations into the same engine. Grok fills the observation face with fifteen event names; is_blocking() returns true only for PreToolUse, and there is no pre-approval hook.
One PreToolUse, two return values
The project has a PreToolUse command hook whose matcher points at a harmless echo. First set timeout to 1 and sleep 5 seconds in the script. Then change the script to exit 2 and write blocked by test to stderr.
Walk both endings: does the tool run, what does the model see, and is the hook failed or blocked. Then explain why the first trip cannot treat “the hook died” as a block.