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