← All posts

Guide

Codex CLI notifications: how the notify hook actually works

Codex CLI notifications: how the notify hook actually works

Almost everything written about Codex's notify hook is guesswork, and most of it is wrong in the same three ways. It is described as a general notification system (it has one event). It is shown reading JSON from stdin (it does not). And it is recommended as the way to approve or block a risky command (it cannot — not by configuration, but by construction).

This guide is written from the Codex source and the current hooks documentation, with every claim below re-checked against a link at the bottom of the page. If you have never seen notify, start at the top. If you already use it, skip to the argv gotcha and per-run wiring — those are the two things people get wrong after they get it working at all.

What notify is, in one sentence

notify is a config key holding an argv array. When the agent finishes a turn, Codex runs that program and hands it a JSON blob describing what just happened. That is the whole feature. There is no matcher, no filtering, no return channel:

# ~/.codex/config.toml
notify = ["/Users/you/bin/codex-notify"]

Two structural facts follow from the implementation and explain most of the confusion around it. First, it fires on exactly one event. Second, it can never affect what the agent does.

One event: agent-turn-complete

In codex-rs/hooks/src/legacy_notify.rs, the notification type is an enum with a single variant:

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum UserNotification {
    #[serde(rename_all = "kebab-case")]
    AgentTurnComplete {
        thread_id: String,
        turn_id: String,
        cwd: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        client: Option<String>,
        input_messages: Vec<String>,
        last_assistant_message: Option<String>,
    },
}

That is the entire notification surface. There is no notify event for "waiting on permission", for a tool call, for session start, or for an error. If you have been searching for the list of Codex notify events, this is the list, and it has one entry. Anything that promises you more is describing Codex's lifecycle hooks, which are a different mechanism covered further down.

Note the filename: legacy_notify.rs. In the current source, notify is implemented as a compatibility shim on top of the newer hooks system — its doc comment describes it as the "legacy notify payload appended as the final argv argument for backward compatibility", and internally it is registered as a hook on the AfterAgent event. It still works, and this article documents the behaviour as it stands today. But it is maintained as a stable wire format rather than the direction of travel, which is worth knowing before you build something elaborate on it.

The payload arrives as an argv argument, not on stdin

This is the single most important thing on the page, and the reason most first attempts fail silently. Codex serializes the event to JSON and appends it as one extra final argument after whatever tokens you configured:

if let Ok(notify_payload) = legacy_notify_json(payload) {
    command.arg(notify_payload);
}

If you have wired up Claude Code hooks before, this is exactly backwards from what your muscle memory expects — Claude pipes the event as JSON on stdin, so every script and snippet in that ecosystem starts with a jq reading standard input. Paste one of those into notify and it will run, read an empty stdin, and do nothing, with no error anywhere. The program ran fine. It just read the wrong place.

Worse, Codex explicitly closes stdin: the child is spawned with all three standard streams set to Stdio::null(). So it is not that stdin happens to be empty — it is wired to nothing on purpose.

The exact payload

The keys are kebab-case, which trips up anyone expecting the snake_case field names from the Rust struct. Here is a real payload, taken from the assertion in the Codex test suite so the shape is exactly what ships:

{
  "type": "agent-turn-complete",
  "thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
  "turn-id": "12345",
  "cwd": "/Users/example/project",
  "client": "codex-tui",
  "input-messages": ["Rename `foo` to `bar` and update the callsites."],
  "last-assistant-message": "Rename complete and verified `cargo build` succeeds."
}

Field by field, and what each is actually good for:

A notify program that works

Here is a complete script. It reads the payload from process.argv[2], guards against every field that can legitimately be missing, and raises a macOS desktop notification. Save it as ~/bin/codex-notify.mjs and chmod +x it:

#!/usr/bin/env node
import { execFile } from 'node:child_process'

// The payload is argv[2]: [0] is node, [1] is this script, [2] is what Codex appended.
const raw = process.argv[2]
if (!raw) process.exit(0)

let p
try {
  p = JSON.parse(raw)
} catch {
  process.exit(0)
}
if (p.type !== 'agent-turn-complete') process.exit(0)

const repo = (p.cwd || '').split('/').filter(Boolean).pop() || 'repo'
const body = (p['last-assistant-message'] || 'Turn finished')
  .replace(/\s+/g, ' ')
  .slice(0, 140)

execFile('osascript', [
  '-e',
  `display notification ${JSON.stringify(body)} with title ${JSON.stringify('Codex · ' + repo)} sound name "Glass"`,
])

Wire it up and test it without waiting for a real turn — just call it with a payload by hand:

node ~/bin/codex-notify.mjs '{"type":"agent-turn-complete","cwd":"/Users/you/project","last-assistant-message":"Tests pass."}'

That is the whole debugging loop for a notify program, and it is worth using: because Codex discards stdout and stderr, a crash inside your script is completely invisible at runtime. Test it standalone first, always.

Phone pings with no script at all

Because the payload is just the last argument, you can often skip the wrapper entirely and point notify straight at curl. The trick is that the flag expecting a value has to be the last token, so the payload lands as its value:

notify = ["curl", "-s", "-m", "5", "-X", "POST", "https://ntfy.sh/your-secret-topic", "--data-binary"]

Install the ntfy app, subscribe to that topic, and finished turns now buzz your pocket with the raw JSON as the message body. Crude, but it is one line and there is no script to maintain.

The bash -c trap

If you reach for a shell one-liner, know that Codex runs your program directly — Command::new(program).args(args), an execve with no shell in between. Nothing is expanded for you: no $VAR, no pipes, no &&, no globs. Those are only available if you invoke a shell yourself.

And when you do, the positional arguments do not land where you expect. With bash -c, the token immediately after the script string becomes $0 — so the appended payload is $0, and $1 is empty:

# WRONG — payload lands in $0, $1 is empty
notify = ["bash", "-c", "echo \"$1\" >> ~/codex.log"]

# RIGHT — the placeholder absorbs $0, payload becomes $1
notify = ["bash", "-c", "echo \"$1\" >> ~/codex.log", "codex-notify"]

This one costs people an afternoon, because the symptom — a log file full of blank lines — looks like the payload is not being sent at all.

Per-run vs global: pick deliberately

The standard advice is to put notify in ~/.codex/config.toml. That works, and it is the right answer when you want one consistent behaviour everywhere. But it is a global switch: from then on, every Codex session you ever start fires that program, including the thirty-second one-liners where a desktop toast is pure noise.

The alternative is the runtime config layer, which is what -c/--config writes to:

codex -c 'notify=["/Users/you/bin/codex-notify.mjs"]'

That configures notify for that invocation only and never touches your config file. Use it for the long-running sessions where you actually intend to walk away, and leave your global config clean.

There is one more wrinkle worth knowing, because it rules out the option most people try next. You cannot commit a notify setting to a repo: notify sits on the project-local config denylist in codex-rs/config/src/loader/mod.rs, alongside model_provider, profile and the various base-URL keys. A repo's .codex/config.toml setting it is ignored. The reasoning is sound — cloning a repository should not silently earn it the right to execute a program on your machine every time an agent finishes thinking.

The denylist applies to the project layer specifically. notify is still honoured from the user, system, managed and runtime layers, and the runtime layer is not filtered by that list — which is precisely why the -c form above works.

The honest limitation: notify cannot hold anything

Here is the part that decides whether notify is the right tool for your problem. Look at how the child process is launched:

command
    .stdin(Stdio::null())
    .stdout(Stdio::null())
    .stderr(Stdio::null());

match command.spawn() {
    Ok(_) => HookResult::Success,
    Err(err) => HookResult::FailedContinue(err.into()),
}

All three streams are null, and Codex calls spawn() without ever awaiting the result. It checks whether the process started, not what it did. Three consequences follow, and none of them are configurable:

So there is no Codex equivalent of Claude Code's blocking PreToolUse on this path. notify tells you a turn finished. It is a doorbell, not a gate. If what you actually wanted was "stop and ask me before running that command", notify is structurally incapable of it, and no amount of config will change that.

When you do need to gate: lifecycle hooks

Codex does have blocking hooks — they are simply a separate system from notify, and conflating the two is the other half of the confusion online. The lifecycle hooks cover SessionStart, SessionEnd, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, UserPromptSubmit, SubagentStart, SubagentStop and Stop. They live in ~/.codex/hooks.json (or a [hooks] table in ~/.codex/config.toml, or a repo's .codex/):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 ~/.codex/hooks/pre_tool_use_policy.py",
            "statusMessage": "Checking Bash command"
          }
        ]
      }
    ]
  }
}

Everything that is true of notify is inverted here. These hooks receive one JSON object on stdin — with session_id, turn_id, tool_name and tool_input — and a PreToolUse hook can deny the call by printing a decision:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by hook."
  }
}

If that JSON looks familiar, it is: it is the same shape Claude Code uses for the same purpose, down to the key names. The older "decision": "block" form and a bare exit code 2 also work.

Two practical caveats before you go all in on them:

So the rule of thumb is short. Tell me when it is donenotify (or the Stop lifecycle hook). Do not let it do that → a PreToolUse lifecycle hook. They are not alternatives to each other; most setups that care want one of each.

Where Backgrind fits

Everything above is the DIY route, and it is a perfectly good one. The reason Backgrind exists is what happens after you have it working: the script is only the easy half. You still need the toast to be visible while you are in a fullscreen game, the ping to follow you to your phone when you leave the desk, and some way to tell three concurrent agents apart.

Backgrind runs Codex as a first-class backend and wires this exact notify hook per-run, using the -c form described above — so a finished Codex turn raises a toast on your desktop and your phone, and your ~/.codex/config.toml is never touched. No script to write, nothing global to clean up later.

What it does not do is pretend the limitation away. Because Codex's notify cannot block, there is no inline approve/deny for Codex sessions — a Codex permission prompt still has to be answered in the terminal. The app says exactly that where you pick the backend, and it is worth knowing before you choose: if inline approvals are the feature you came for, that is a Claude Code capability, and it is a genuine reason to run Claude in that tab instead.

Frequently asked questions

What events does the Codex notify hook fire on?

Exactly one — agent-turn-complete. The notification enum in the Codex source has a single variant, so there is no notify event for permission prompts, tool calls, or errors. Use lifecycle hooks for those.

Does the payload come on stdin or as an argument?

As one extra final argv argument, appended after your configured tokens. Stdin is explicitly set to null. This is the opposite of Claude Code and the most common reason a ported script silently does nothing.

Can a notify hook block or approve a tool call?

No. Codex nulls all three standard streams and never awaits the process, so there is no return channel by construction. Use a PreToolUse lifecycle hook with permissionDecision: "deny" to gate an action.

Can I set notify for one run only?

Yes: codex -c 'notify=["/path/to/program"]'. The runtime layer is not filtered by the project-local denylist that blocks notify in a repo's .codex/config.toml, so this works and leaves your global config untouched.

Why is $1 empty in my bash -c notify command?

With bash -c the token after the script string becomes $0, and the payload is appended there. Add a placeholder argument — ["bash", "-c", "…", "codex-notify"] — to push the payload into $1.

Verified August 2, 2026 against codex-rs/hooks/src/legacy_notify.rs (single enum variant, kebab-case keys, command.arg(payload), Stdio::null(), and the payload example asserted in its test module), codex-rs/hooks/src/registry.rs (Command::new(program).args(args) — no shell), codex-rs/config/src/loader/mod.rs (the project-local denylist containing notify, and the config layer order), and the Codex hooks documentation (lifecycle event names, hooks.json shape, stdin input, permissionDecision, and hook trust). Codex moves quickly — if a detail here no longer matches, the source files above are the ones to check.