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