Unwait

Codex hooks and config.toml: notify is no longer the whole story

· 7 min read codex codex cli hooks config toml sandbox ai coding agents

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:

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:

  1. Built-in defaults
  2. ~/.codex/config.toml
  3. A selected profile, ~/.codex/<name>.config.toml, chosen with --profile <name>
  4. Project .codex/config.toml, trusted projects only
  5. CLI flags and -c key=value overrides

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:

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:

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

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.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts