hooks.md +553 −0 added
1# Hooks
2
3Hooks are an extensibility framework for Codex. They allow
4you to inject your own scripts into the agentic loop, enabling features such as:
5
6- Send the conversation to a custom logging/analytics engine
7- Scan your team's prompts to block accidentally pasting API keys
8- Summarize conversations to create persistent memories automatically
9- Run a custom validation check when a conversation turn stops, enforcing standards
10- Customize prompting when in a certain directory
11
12Hooks are behind a feature flag in `config.toml`:
13
14```toml
15[features]
16codex_hooks = true
17```
18
19Runtime behavior to keep in mind:
20
21- Matching hooks from multiple files all run.
22- Multiple matching command hooks for the same event are launched concurrently,
23 so one hook cannot prevent another matching hook from starting.
24- `PreToolUse`, `PermissionRequest`, `PostToolUse`, `UserPromptSubmit`, and
25 `Stop` run at turn scope.
26
27## Where Codex looks for hooks
28
29Codex discovers hooks next to active config layers in either of these forms:
30
31- `hooks.json`
32- inline `[hooks]` tables inside `config.toml`
33
34Installed plugins can also bundle lifecycle config through their plugin
35manifest or a default `hooks/hooks.json` file. See [Build
36plugins](https://developers.openai.com/codex/plugins/build#bundled-mcp-servers-and-lifecycle-config) for the
37plugin packaging rules.
38
39In practice, the four most useful locations are:
40
41- `~/.codex/hooks.json`
42- `~/.codex/config.toml`
43- `<repo>/.codex/hooks.json`
44- `<repo>/.codex/config.toml`
45
46If more than one hook source exists, Codex loads all matching hooks.
47Higher-precedence config layers do not replace lower-precedence hooks.
48If a single layer contains both `hooks.json` and inline `[hooks]`, Codex
49merges them and warns at startup. Prefer one representation per layer.
50
51Project-local hooks load only when the project `.codex/` layer is trusted. In
52untrusted projects, Codex still loads user and system hooks from their own
53active config layers.
54
55## Config shape
56
57Hooks are organized in three levels:
58
59- A hook event such as `PreToolUse`, `PostToolUse`, or `Stop`
60- A matcher group that decides when that event matches
61- One or more hook handlers that run when the matcher group matches
62
63```json
64{
65 "hooks": {
66 "SessionStart": [
67 {
68 "matcher": "startup|resume",
69 "hooks": [
70 {
71 "type": "command",
72 "command": "python3 ~/.codex/hooks/session_start.py",
73 "statusMessage": "Loading session notes"
74 }
75 ]
76 }
77 ],
78 "PreToolUse": [
79 {
80 "matcher": "Bash",
81 "hooks": [
82 {
83 "type": "command",
84 "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py\"",
85 "statusMessage": "Checking Bash command"
86 }
87 ]
88 }
89 ],
90 "PermissionRequest": [
91 {
92 "matcher": "Bash",
93 "hooks": [
94 {
95 "type": "command",
96 "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/permission_request.py\"",
97 "statusMessage": "Checking approval request"
98 }
99 ]
100 }
101 ],
102 "PostToolUse": [
103 {
104 "matcher": "Bash",
105 "hooks": [
106 {
107 "type": "command",
108 "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/post_tool_use_review.py\"",
109 "statusMessage": "Reviewing Bash output"
110 }
111 ]
112 }
113 ],
114 "UserPromptSubmit": [
115 {
116 "hooks": [
117 {
118 "type": "command",
119 "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/user_prompt_submit_data_flywheel.py\""
120 }
121 ]
122 }
123 ],
124 "Stop": [
125 {
126 "hooks": [
127 {
128 "type": "command",
129 "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/stop_continue.py\"",
130 "timeout": 30
131 }
132 ]
133 }
134 ]
135 }
136}
137```
138
139Notes:
140
141- `timeout` is in seconds.
142- If `timeout` is omitted, Codex uses `600` seconds.
143- `statusMessage` is optional.
144- Commands run with the session `cwd` as their working directory.
145- For repo-local hooks, prefer resolving from the git root instead of using a
146 relative path such as `.codex/hooks/...`. Codex may be started from a
147 subdirectory, and a git-root-based path keeps the hook location stable.
148
149Equivalent inline TOML in `config.toml`:
150
151```toml
152[features]
153codex_hooks = true
154
155[[hooks.PreToolUse]]
156matcher = "^Bash$"
157
158[[hooks.PreToolUse.hooks]]
159type = "command"
160command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py"'
161timeout = 30
162statusMessage = "Checking Bash command"
163
164[[hooks.PostToolUse]]
165matcher = "^Bash$"
166
167[[hooks.PostToolUse.hooks]]
168type = "command"
169command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/post_tool_use_review.py"'
170timeout = 30
171statusMessage = "Reviewing Bash output"
172```
173
174## Managed hooks from `requirements.toml`
175
176Enterprise-managed requirements can also define hooks inline under `[hooks]`.
177This is useful when admins want to enforce the hook configuration while
178delivering the actual scripts through MDM or another device-management system.
179
180```toml
181[features]
182codex_hooks = true
183
184[hooks]
185managed_dir = "/enterprise/hooks"
186windows_managed_dir = 'C:\enterprise\hooks'
187
188[[hooks.PreToolUse]]
189matcher = "^Bash$"
190
191[[hooks.PreToolUse.hooks]]
192type = "command"
193command = "python3 /enterprise/hooks/pre_tool_use_policy.py"
194timeout = 30
195statusMessage = "Checking managed Bash command"
196```
197
198Notes for managed hooks:
199
200- `managed_dir` is used on macOS and Linux.
201- `windows_managed_dir` is used on Windows.
202- Codex does not distribute the scripts in `managed_dir`; your enterprise
203 tooling must install and update them separately.
204- Managed hook commands should use absolute script paths under the configured
205 managed directory.
206
207## Matcher patterns
208
209The `matcher` field is a regex string that filters when hooks fire. Use `"*"`,
210`""`, or omit `matcher` entirely to match every occurrence of a supported
211event.
212
213Only some current Codex events honor `matcher`:
214
215| Event | What `matcher` filters | Notes |
216| ------------------- | ---------------------- | ------------------------------------------------------------ |
217| `PermissionRequest` | tool name | Support includes `Bash`, `apply_patch`\*, and MCP tool names |
218| `PostToolUse` | tool name | Support includes `Bash`, `apply_patch`\*, and MCP tool names |
219| `PreToolUse` | tool name | Support includes `Bash`, `apply_patch`\*, and MCP tool names |
220| `SessionStart` | start source | Current runtime values are `startup`, `resume`, and `clear` |
221| `UserPromptSubmit` | not supported | Any configured `matcher` is ignored for this event |
222| `Stop` | not supported | Any configured `matcher` is ignored for this event |
223
224\*For `apply_patch`, matchers can also use `Edit` or `Write`.
225
226Examples:
227
228- `Bash`
229- `^apply_patch$`
230- `Edit|Write`
231- `mcp__filesystem__read_file`
232- `mcp__filesystem__.*`
233- `startup|resume|clear`
234
235## Common input fields
236
237Every command hook receives one JSON object on `stdin`.
238
239These are the shared fields you will usually use:
240
241| Field | Type | Meaning |
242| ----------------- | ---------------- | ------------------------------------------- |
243| `session_id` | `string` | Current session or thread id. |
244| `transcript_path` | `string \| null` | Path to the session transcript file, if any |
245| `cwd` | `string` | Working directory for the session |
246| `hook_event_name` | `string` | Current hook event name |
247| `model` | `string` | Active model slug |
248
249Turn-scoped hooks list `turn_id` in their event-specific tables.
250
251If you need the full wire format, see [Schemas](#schemas).
252
253## Common output fields
254
255`SessionStart`, `UserPromptSubmit`, and `Stop` support these shared JSON
256fields:
257
258```json
259{
260 "continue": true,
261 "stopReason": "optional",
262 "systemMessage": "optional",
263 "suppressOutput": false
264}
265```
266
267| Field | Effect |
268| ---------------- | ----------------------------------------------- |
269| `continue` | If `false`, marks that hook run as stopped |
270| `stopReason` | Recorded as the reason for stopping |
271| `systemMessage` | Surfaced as a warning in the UI or event stream |
272| `suppressOutput` | Parsed today but not yet implemented |
273
274Exit `0` with no output is treated as success and Codex continues.
275
276`PreToolUse` and `PermissionRequest` support `systemMessage`, but `continue`,
277`stopReason`, and `suppressOutput` aren't currently supported for those events.
278
279`PostToolUse` supports `systemMessage`, `continue: false`, and `stopReason`.
280`suppressOutput` is parsed but not currently supported for that event.
281
282## Hooks
283
284### SessionStart
285
286`matcher` is applied to `source` for this event.
287
288Fields in addition to [Common input fields](#common-input-fields):
289
290| Field | Type | Meaning |
291| -------- | -------- | ---------------------------------------------- |
292| `source` | `string` | How the session started: `startup` or `resume` |
293
294Plain text on `stdout` is added as extra developer context.
295
296JSON on `stdout` supports [Common output fields](#common-output-fields) and this
297hook-specific shape:
298
299```json
300{
301 "hookSpecificOutput": {
302 "hookEventName": "SessionStart",
303 "additionalContext": "Load the workspace conventions before editing."
304 }
305}
306```
307
308That `additionalContext` text is added as extra developer context.
309
310### PreToolUse
311
312`PreToolUse` can intercept Bash, file edits performed through `apply_patch`,
313and MCP tool calls. It is still a guardrail rather than a complete enforcement
314boundary because Codex can often perform equivalent work through another
315supported tool path.
316
317This doesn't intercept all shell calls yet, only the simple ones. The newer
318 `unified_exec` mechanism allows richer streaming stdin/stdout handling of
319 shell, but interception is incomplete. Similarly, this doesn't intercept
320 `WebSearch` or other non-shell, non-MCP tool calls.
321
322`matcher` is applied to `tool_name` and matcher aliases. For file edits through
323`apply_patch`, matchers can use `apply_patch`, `Edit`, or `Write`; hook input
324still reports `tool_name: "apply_patch"`.
325
326Fields in addition to [Common input fields](#common-input-fields):
327
328| Field | Type | Meaning |
329| ------------- | ------------ | --------------------------------------------------------------------------------------------------------- |
330| `turn_id` | `string` | Codex-specific extension. Active Codex turn id |
331| `tool_name` | `string` | Canonical hook tool name, such as `Bash`, `apply_patch`, or an MCP name like `mcp__fs__read` |
332| `tool_use_id` | `string` | Tool-call id for this invocation |
333| `tool_input` | `JSON value` | Tool-specific input. `Bash` and `apply_patch` use `tool_input.command` while MCP tools send all the args. |
334
335Plain text on `stdout` is ignored.
336
337JSON on `stdout` can use `systemMessage` and can block a Bash command with this
338hook-specific shape:
339
340```json
341{
342 "hookSpecificOutput": {
343 "hookEventName": "PreToolUse",
344 "permissionDecision": "deny",
345 "permissionDecisionReason": "Destructive command blocked by hook."
346 }
347}
348```
349
350Codex also accepts this older block shape:
351
352```json
353{
354 "decision": "block",
355 "reason": "Destructive command blocked by hook."
356}
357```
358
359You can also use exit code `2` and write the blocking reason to `stderr`.
360
361`permissionDecision: "allow"` and `"ask"`, legacy `decision: "approve"`,
362`updatedInput`, `additionalContext`, `continue: false`, `stopReason`, and
363`suppressOutput` are parsed but not supported yet, so they fail open.
364
365### PermissionRequest
366
367`PermissionRequest` runs when Codex is about to ask for approval, such as a
368shell escalation or managed-network approval. It can allow the request, deny
369the request, or decline to decide and let the normal approval prompt continue.
370It doesn't run for commands that don't need approval.
371
372`matcher` is applied to `tool_name` and matcher aliases. Current canonical
373values include `Bash`, `apply_patch`, and MCP tool names such as
374`mcp__server__tool`; `apply_patch` also matches `Edit` and `Write`.
375
376Fields in addition to [Common input fields](#common-input-fields):
377
378| Field | Type | Meaning |
379| ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------- |
380| `turn_id` | `string` | Codex-specific extension. Active Codex turn id |
381| `tool_name` | `string` | Canonical hook tool name, such as `Bash`, `apply_patch`, or an MCP name like `mcp__fs__read` |
382| `tool_input` | `JSON value` | Tool-specific input. `Bash` and `apply_patch` use `tool_input.command` while MCP tools send all the args. |
383| `tool_input.description` | `string \| null` | Human-readable approval reason, when Codex has one |
384
385Plain text on `stdout` is ignored.
386
387To approve the request, return:
388
389```json
390{
391 "hookSpecificOutput": {
392 "hookEventName": "PermissionRequest",
393 "decision": {
394 "behavior": "allow"
395 }
396 }
397}
398```
399
400To deny the request, return:
401
402```json
403{
404 "hookSpecificOutput": {
405 "hookEventName": "PermissionRequest",
406 "decision": {
407 "behavior": "deny",
408 "message": "Blocked by repository policy."
409 }
410 }
411}
412```
413
414If multiple matching hooks return decisions, any `deny` wins. Otherwise, an
415`allow` lets the request proceed without surfacing the approval prompt. If no
416matching hook decides, Codex uses the normal approval flow.
417
418Don't return `updatedInput`, `updatedPermissions`, or `interrupt` for
419`PermissionRequest`; those fields are reserved for future behavior and fail
420closed today.
421
422### PostToolUse
423
424`PostToolUse` runs after supported tools produce output, including Bash,
425`apply_patch`, and MCP tool calls. For Bash, it also runs after commands that
426exit with a non-zero status. It can't undo side effects from the tool that
427already ran.
428
429This doesn't intercept all shell calls yet, only the simple ones. The newer
430 `unified_exec` mechanism allows richer streaming stdin/stdout handling of
431 shell, but interception is incomplete. Similarly, this doesn't intercept
432 `WebSearch` or other non-shell, non-MCP tool calls.
433
434`matcher` is applied to `tool_name` and matcher aliases. For file edits through
435`apply_patch`, matchers can use `apply_patch`, `Edit`, or `Write`; hook input
436still reports `tool_name: "apply_patch"`.
437
438Fields in addition to [Common input fields](#common-input-fields):
439
440| Field | Type | Meaning |
441| --------------- | ------------ | --------------------------------------------------------------------------------------------------------- |
442| `turn_id` | `string` | Codex-specific extension. Active Codex turn id |
443| `tool_name` | `string` | Canonical hook tool name, such as `Bash`, `apply_patch`, or an MCP name like `mcp__fs__read` |
444| `tool_use_id` | `string` | Tool-call id for this invocation |
445| `tool_input` | `JSON value` | Tool-specific input. `Bash` and `apply_patch` use `tool_input.command` while MCP tools send all the args. |
446| `tool_response` | `JSON value` | Tool-specific output. For MCP tools, this is the MCP call result. |
447
448Plain text on `stdout` is ignored.
449
450JSON on `stdout` can use `systemMessage` and this hook-specific shape:
451
452```json
453{
454 "decision": "block",
455 "reason": "The Bash output needs review before continuing.",
456 "hookSpecificOutput": {
457 "hookEventName": "PostToolUse",
458 "additionalContext": "The command updated generated files."
459 }
460}
461```
462
463That `additionalContext` text is added as extra developer context.
464
465For this event, `decision: "block"` doesn't undo the completed Bash command.
466Instead, Codex records the feedback, replaces the tool result with that
467feedback, and continues the model from the hook-provided message.
468
469You can also use exit code `2` and write the feedback reason to `stderr`.
470
471To stop normal processing of the original tool result after the command has
472already run, return `continue: false`. Codex will replace the tool result with
473your feedback or stop text and continue from there.
474
475`updatedMCPToolOutput` and `suppressOutput` are parsed but not supported yet,
476so they fail open.
477
478### UserPromptSubmit
479
480`matcher` isn't currently used for this event.
481
482Fields in addition to [Common input fields](#common-input-fields):
483
484| Field | Type | Meaning |
485| --------- | -------- | ---------------------------------------------- |
486| `turn_id` | `string` | Codex-specific extension. Active Codex turn id |
487| `prompt` | `string` | User prompt that's about to be sent |
488
489Plain text on `stdout` is added as extra developer context.
490
491JSON on `stdout` supports [Common output fields](#common-output-fields) and
492this hook-specific shape:
493
494```json
495{
496 "hookSpecificOutput": {
497 "hookEventName": "UserPromptSubmit",
498 "additionalContext": "Ask for a clearer reproduction before editing files."
499 }
500}
501```
502
503That `additionalContext` text is added as extra developer context.
504
505To block the prompt, return:
506
507```json
508{
509 "decision": "block",
510 "reason": "Ask for confirmation before doing that."
511}
512```
513
514You can also use exit code `2` and write the blocking reason to `stderr`.
515
516### Stop
517
518`matcher` isn't currently used for this event.
519
520Fields in addition to [Common input fields](#common-input-fields):
521
522| Field | Type | Meaning |
523| ------------------------ | ---------------- | ------------------------------------------------- |
524| `turn_id` | `string` | Codex-specific extension. Active Codex turn id |
525| `stop_hook_active` | `boolean` | Whether this turn was already continued by `Stop` |
526| `last_assistant_message` | `string \| null` | Latest assistant message text, if available |
527
528`Stop` expects JSON on `stdout` when it exits `0`. Plain text output is invalid
529for this event.
530
531JSON on `stdout` supports [Common output fields](#common-output-fields). To keep
532Codex going, return:
533
534```json
535{
536 "decision": "block",
537 "reason": "Run one more pass over the failing tests."
538}
539```
540
541You can also use exit code `2` and write the continuation reason to `stderr`.
542
543For this event, `decision: "block"` doesn't reject the turn. Instead, it tells
544Codex to continue and automatically creates a new continuation prompt that acts
545as a new user prompt, using your `reason` as that prompt text.
546
547If any matching `Stop` hook returns `continue: false`, that takes precedence
548over continuation decisions from other matching `Stop` hooks.
549
550## Schemas
551
552If you need the exact current wire format, see the generated schemas in the
553[Codex GitHub repository](https://github.com/openai/codex/tree/main/codex-rs/hooks/schema/generated).