Codex hooks and config.toml: notify is no longer the whole story
Codex CLI now ships twelve lifecycle hooks, on by default, with JSON on stdin and the power to block. What changed from notify, how config.toml layers and trust decide what runs, the notify sharp edges that still apply, and what sandbox_mode, approval_policy, --full-auto and --yolo actually set.
If you read that Codex only exposes one automation key, notify, that was true when it was written and it is not true now. We wrote it ourselves, in our Codex vs Claude Code comparison, with a note that a richer hook system was sitting in Codex's source. It shipped.
On the Codex CLI we have installed, 0.147.0, codex features list shows it plainly:
hooks stable true
Stable, and enabled by default. Twelve events, JSON on stdin, and several of them can block. This post covers what changed, how config.toml decides what actually runs, and the parts of the old notify contract that still bite.
The events
SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop, and Interrupt.
If you have written Claude Code hooks, that list will look familiar, and so will the output shapes. That is useful: a script can serve both agents with a thin branch on hook_event_name.
The one that matters most for anyone who built on notify: UserPromptSubmit exists. The biggest limitation of the old contract was that you learned when a turn ended but never when it began, so you could not measure a wait or show state while Codex worked. With UserPromptSubmit and Stop you now have both ends.
notify versus hooks, side by side
notify |
hooks | |
|---|---|---|
| Payload | JSON as the last argv argument | one JSON object on stdin |
| Events | turn complete only | twelve lifecycle events |
| Execution | direct exec, fire and forget | runs with a timeout, output is read |
| Can block | no | yes, on several events |
| Where | top-level key in user config.toml |
hooks.json or [hooks] tables, user or repo |
Every hook payload carries session_id, transcript_path, cwd, hook_event_name, and model, with turn_id on turn-scoped events.
notify still works. Unwait's own Codex integration still runs on it today, and the installed binary carries a legacy_notify compatibility path. But if you are starting fresh, there is no reason to build on the narrower contract.
Configuring hooks
Either a hooks.json file:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 ~/.codex/hooks/session_start.py",
"statusMessage": "Loading session notes"
}
]
}
]
}
}
or the same thing inline in config.toml:
[[hooks.SessionStart]]
matcher = "^compact$"
[[hooks.SessionStart.hooks]]
type = "command"
command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/session_start.py"'
Both are discovered at ~/.codex/ and at <repo>/.codex/.
Two details that will otherwise cost you time:
matcheris ignored byUserPromptSubmit,Stop, andInterrupt. Only some events honor it. A matcher on those three does nothing, silently.- Timeouts are long by default and short at the edges. Most hooks get 600 seconds when
timeoutis omitted.SessionEndandInterruptdefault to 1 second and cap at 3, so cleanup work belongs somewhere else.
Blocking
PreToolUse denies a tool call with this, or with exit code 2 and a reason on stderr:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook."
}
}
UserPromptSubmit can block a prompt, and Stop or SubagentStop can refuse to let the turn end, which is how you build "do not stop until the tests pass":
{
"decision": "block",
"reason": "Run one more pass over the failing tests."
}
PostToolUse can report back but cannot undo what already ran. It replaces the tool result with your feedback; the file is still written.
Trust decides what runs
This is the part to understand before you commit a .codex/hooks.json for your team.
Codex requires you to review and trust the exact hook definition before a non-managed hook can run. Project-local hooks load only when the project's .codex/ layer is trusted, and installing or enabling a plugin does not automatically trust its hooks. An untrusted project skips its entire .codex/ layer: config, hooks, and rules.
Trust is recorded per path in your user config:
[projects."/Users/you/code/app"]
trust_level = "trusted"
For organizations, allow_managed_hooks_only = true ignores user, project, and session hooks while still running managed ones. It only works in requirements.toml; set in config.toml it does nothing.
That is the same principle we keep landing on for Claude Code in the security post: a repository is input, not authority, so it should not get to run code on your machine just because you cloned it.
How config.toml layers
Lowest to highest:
- Built-in defaults
~/.codex/config.toml- A selected profile,
~/.codex/<name>.config.toml, chosen with--profile <name> - Project
.codex/config.toml, trusted projects only - CLI flags and
-c key=valueoverrides
CODEX_HOME moves the whole ~/.codex directory.
Some keys cannot be set from a project file, no matter how trusted: notify, model_provider, model_providers, openai_base_url, chatgpt_base_url, profile, otel, and authentication settings. They stay in your user config. That is why a notify line committed to a repository never fires.
The notify sharp edges that still apply
If you keep notify, or maintain a tool that writes it, these come from running it in production:
The array is exec'd directly, with no shell. No variable expansion, no environment prefix, no ~. It also means a path that does not exist fails on every single turn. We shipped exactly that bug: a build that wrote a notify entry pointing at a script it had skipped installing on Windows, which did not just fail to connect, it broke the user's existing Codex notifications until we caught it.
One key, one owner. Only one command can hold notify. Other tools handle this by chaining: we found a real config where another app had taken notify and passed our script along as an argument:
notify = ["/Applications/Codex Computer Use.app/.../SkyComputerUseClient", "turn-ended", "--previous-notify", "[\"/Users/x/.unwait/unwait-codex-notify.sh\"]"]
If your tool's uninstaller treats any line mentioning your script as yours, it deletes someone else's configuration. Detect ownership by the whole line, not by substring. Hooks remove this whole class of problem, since each event holds an array of handlers.
The rest of the old contract is in our notify guide, which is still accurate for notify itself.
sandbox_mode and approval_policy, precisely
Two independent dials, and most confusion comes from treating them as one.
sandbox_mode decides what commands can reach:
read-only: inspect files, no edits or commands without approval.workspace-write: the default. Edit inside the workspace and run routine local commands there.danger-full-access: no filesystem or network boundary.
Network is restricted by default. In workspace-write, sandbox_workspace_write.network_access = true opens outbound access, and sandbox_workspace_write.writable_roots adds directories. Enforcement is native per platform: Seatbelt on macOS, bubblewrap on Linux and WSL2.
approval_policy decides when Codex stops to ask:
on-request: work inside the sandbox, ask to go beyond it.never: never stop for approval, still inside whatever sandbox is set.- A granular object with
sandbox_approval,rules,mcp_elicitations,request_permissions, andskill_approvalflags.
untrusted is retired. If an old config or blog post uses it, migrate.
Setting approvals_reviewer = "auto_review" routes eligible approvals through a reviewer agent that checks for exfiltration, credential probing, destructive actions, and weakened security. It is the closest analogue to Claude Code's auto mode.
What the flags actually set
--full-autois deprecated. It now maps to--sandbox workspace-writeand prints a warning, which is the default anyway.--yolois an alias for--dangerously-bypass-approvals-and-sandbox. It removes both dials at once: no sandbox and no approvals.
The documented recommendations are more useful than either flag:
| Intent | Flags |
|---|---|
| Normal interactive work | --sandbox workspace-write --ask-for-approval on-request |
| Read-only exploration | --sandbox read-only --ask-for-approval on-request |
| CI, non-interactive | --sandbox read-only --ask-for-approval never |
| Fewer prompts, still reviewed | add -c approvals_reviewer=auto_review |
Note the CI row. never is safe there because the sandbox is read-only. Pairing never with danger-full-access is --yolo spelled out, and the same reasoning from our Claude Code bypass post applies: the boundary has to live somewhere, and if it is not the prompt, it had better be a container.
Where to start
Run codex features list and confirm hooks is on. Write one Stop hook that logs its stdin to a file, run a prompt, and read what arrives, because the payload is the contract and reading it beats reading about it. Keep notify and provider settings in ~/.codex/config.toml, commit hooks in .codex/hooks.json knowing each teammate has to trust them, and set sandbox_mode and approval_policy explicitly rather than reaching for a flag.
The shift worth noticing is not any single event. It is that Codex and Claude Code now expose nearly the same lifecycle to external tools, so the question of which agent your tooling can support well is mostly settled, and what is left is how each handles trust.