Guide
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:
-
type— alwaysagent-turn-completetoday. Check it anyway; it is the tag that would distinguish a second event if one is ever added. -
thread-idandturn-id— stable identifiers.thread-idis the one to key on if you want to group notifications per conversation. -
cwd— the working directory. In practice the most useful field on the whole payload: its basename is how you label "which repo just finished" when you are running several agents at once. -
client—codex-tuifor the interactive TUI. Omitted entirely when unset, so read it defensively. -
input-messages— the user messages that opened the turn. -
last-assistant-message— the agent's closing message. This is your notification body. It can be absent, and it can be long, so default it and truncate it.
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:
- It cannot return a decision. There is no channel back. Nothing your program prints or exits with is read.
- It cannot delay the agent. Codex does not wait, so a slow program does not hold the turn — it just runs late, alongside a session that has already moved on.
- It cannot report its own failure. A syntax error in your script is indistinguishable from success. Only a failure to spawn — a bad path, a file that is not executable — is even noticed, and it is non-fatal.
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:
- There is no per-invocation flag. Unlike
notify, lifecycle hooks load only from those config files — you cannot pass one for a single run. The only related one-off flag is--dangerously-bypass-hook-trust, which is about trust, not about defining a hook. - Hooks must be trusted before they run. Codex requires you to review and trust
the exact hook definition via the
/hookscommand; an untrusted non-managed hook is marked for review and skipped. If you write a hook and nothing happens, check this before you debug the script.
So the rule of thumb is short. Tell me when it is done → notify (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.