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