OpenAI Codex · Hooks

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.

Course goalAfter this you can name three things: which of the four types actually enter the runtime table; why a hook timeout cannot stop a tool; and why 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.
Try it first · In what order hooks fire in one turn
Same session timeline, swap the return value, see who can still change the next step
How it answers this time
Five return values hang on the same axis. Timeouts and unimplemented types cannot change the next step. An explicit deny, or a block that carries a prompt, can.
Main axis · hooks one turn walks
Side axis · sub-agents on ThreadSpawn only
What this step gets, what it can change
GotHasn’t started yet.
Can changePick a return value, then play.
Tool, approval, context
ToolNot at PreToolUse yet.
ContextEmpty.
Logic trail · each animation step maps to a source span
  1. Protocol enum lists eleven event namesprotocol.rs L1510
  2. Config accepts four types; the last two are empty structshook_config.rs L183
  3. Discovery writes Prompt and Agent as not supported yetdiscovery.rs L626
  4. Runtime table only accepts Command and McpToolengine/mod.rs L107
  5. Only a sync hook can apply a control effectengine/mod.rs L146
  6. Timeout writes error; should_block stays falsecommand_runner.rs L317
  7. Exit code 2 plus stderr is what marks Blockedpre_tool_use.rs L261
  8. Allow maps to a one-shot Approvedapprovals.rs L465
  9. A Stop block only continues at the turn layer if it carries a promptturn.rs L509
  10. SessionEnd treats exit 0 as done and drops stdoutsession_end.rs L109
Hit Play. Watch what each hook gets in one turn, what it can change, and when it’s already too late.
A name is not a capabilityConfig can write Prompt and Agent; discovery tears those cards off. What can actually run is commands and MCP tools.
Fail openTimeouts, crashes, and illegal JSON all leave the control bit at false. To block, give an explicit deny, or exit code 2 plus a reason.
Position sets the contractBlock after the tool ran, and you block the result. Write JSON at teardown, and stdout is dropped.
Teaching sketch:The main axis is compressed to eight points; compaction and sub-agents sit on a side path, to show firing order and contract gaps. Line numbers on the logic trail map to openai/codex commit 4f39251a01.
Idea 1 · Recognized and runnable are two tables
What problem it solves

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.

What the idea is

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

Protocol enum 11 events, 4 types Config parse Prompt / Agent are empty structs Runtime: Command / McpTool skip: not supported yet The engine type is literally named ClaudeHooksEngine Claude-compatible hooks.json is the product door: catch four names first, fill executors later The wire enum omits SessionEnd, because teardown never inspects stdout
Write the three tables apart: recognized, parseable, and runnable are three different facts.

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

Why it lasts

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.

Idea 2 · Fail open
What problem it solves

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.

What the idea is

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

PreToolUse Timeout / crash Exit code 2 + reason Failed, keep running the tool Blocked, skip the tool handle_any_tool RespondToModel If the hook itself crashes, the tool still runs. That is fail open.
Same hook point: timeout and an explicit block take the tool two different ways.

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.

To block, give a reason. Silence and timeouts both pass.
Why it lasts

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.

Idea 3 · Too late cannot undo what already happened
What problem it solves

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.

What the idea is

Eleven hook points line up on the lifecycle. The main axis is SessionStartUserPromptSubmitPreToolUsePermissionRequest → tool → PostToolUsePreCompactPostCompactStopSessionEnd. 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

Why it lasts

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?

Side-by-side · Three ways to take the same hooks.json

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.

Both sides checked against source · 2026-08-22

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.

Both sides checked against source · 2026-08-22 · DSH · Plugin waterfall
Classroom Exercise
01

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.

Takeaway:Protocol, config, and runtime are three tables; only commands and MCP can run. Failures fail open — to block, give a reason. What each hook can change is bound to its place on the lifecycle. After teardown, and after the tool has run, it is already too late to change what happened.